mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix: keep foreign discovery from throwing, and correct the adoption docs (release review)
candidatesFrom had moved outside the try when the transports moved to runForeignProbe, so a classifier failure would have reached the route as a 500. The module promises it never throws, so the guard is back for local and docker. Docs: - The api reference said `blocked` waits are always refused, and listed Ralph as refused while two of its file routes were not (they are now). - The invariants doc and CLAUDE.md gain the rules this review turned up: target syntax in names, the ping before list-panes, nothing typed into an adopted pane, quoteAdoptLaunch. - CLAUDE.md no longer says every claude session gets hooks or a stop wait. - ForeignTmuxSession's mode and workingDir describe the first listed pane, not the active one. - The wiki had no page on adoption: The Dashboard gains an Opened outside Codeman section, and the remote and docker pages point at it. docs/docker-cases.md now separates an adopted container from an adopted tmux session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25214fcc5f
commit
2d7cd49449
11 files changed
+104
-30
No files matched your search
@@ -127,7 +127,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
@@ -175,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
|
||||
|
||||
**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md`
|
||||
**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start (`startShell()`'s `clear\n` submitted a half-typed command, and `shell` is also the answer for vim or psql). ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id, and a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`) is refused there too, or the `has-session` guard passes and the join lands elsewhere. ⚠️ A stopped tmux server holds our stdout pipe open past its client, so the probe pings each server with stdout at `/dev/null` first and `runForeignProbe()` lets go of the pipe at the deadline; never give `list-panes` the real stdout unpinged. ⚠️ The launch line goes through `quoteAdoptLaunch()`, never a bare `JSON.stringify()`, or the ssh attach's `$PATH` expands on this host. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md`
|
||||
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
|
||||
@@ -201,7 +201,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order (Alt+[ / Alt+] and Ctrl+Tab included), except the phone swipe, which walks the drawn order like the strip's arrow keys (`nextSession({ visual: true })` through `_stripSessionOrder()`), and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session Codeman starts installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()` (an adopted session installs none: it has no workspace of ours), gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
|
||||
|
||||
|
||||
@@ -1125,8 +1125,10 @@ browser sends them only on an explicit scan. `?force=1` skips the local cache.
|
||||
`adoptedBy` names the session already wrapping that target. `notes` says why a location
|
||||
gave nothing, each opening with where it came from (`local`, `docker <name>`,
|
||||
`remote <label>`). A session whose name or socket path holds characters Codeman will not
|
||||
pass to a shell is skipped and counted in a note. Sessions on a Codeman socket never
|
||||
appear.
|
||||
pass to a shell, or whose name tmux reads as a target rather than a name (one starting
|
||||
with `=`, `@` or `%`, or a bare `~`), is skipped and counted in a note. A tmux server that
|
||||
does not answer within 2 seconds costs only its own rows and gets a note, and a scan cut
|
||||
short keeps the rows it read and says so in one. Sessions on a Codeman socket never appear.
|
||||
|
||||
`POST /api/v1/sessions/adopt` wraps one, with `{ "id": "...", "docker": true, "remote":
|
||||
true }` (the two flags must match the listing the id came from; `name` and
|
||||
@@ -1144,8 +1146,10 @@ comes from the pane's process tree.
|
||||
Closing the session detaches. The foreign session keeps running, and only Codeman's
|
||||
wrapper and its grouped `codeman-view-<id>` session go, so `DELETE` removes nothing that
|
||||
belongs to someone else. Codeman did not start this agent, so these refuse an adopted
|
||||
session: respawn, Ralph, auto-clear, auto-compact, auto-resume, custom model endpoints,
|
||||
prompt uploads and the image watcher. `stop` and `blocked` waits are a 400, the git
|
||||
session: respawn, Ralph (its config and the `fix-plan/write`, `fix-plan/read` and
|
||||
`ralph-prompt/write` file routes), auto-clear, auto-compact, auto-resume, custom model
|
||||
endpoints, prompt uploads and the image watcher. `stop` waits are a 400, and so are
|
||||
`blocked` waits unless Codeman reads that CLI's dialogs off its screen (codex), the git
|
||||
routes answer `unsupported` with `reason: "adopted"`, and `last-response` answers empty,
|
||||
since Codeman knows no transcript for it. Its `paneExit` stays absent, because the pane
|
||||
holds a `tmux attach` and not the agent. Reboot restore does not rebuild one.
|
||||
|
||||
@@ -99,13 +99,15 @@ Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, ne
|
||||
|
||||
**Adopted sessions** (#510; `src/foreign-tmux.ts` pure, `src/foreign-tmux-discovery.ts` transport, `foreign-sessions.js`, `GET /api/mux/foreign`, `POST /api/sessions/adopt`): a tmux session a human started is listed on the home screen, and one click wraps it in a tab. Like remote-SSH and Docker it is a **LOCATION OVERLAY**, never a `SessionMode`: `Session.adopt` carries the connection, the mode comes from a bounded walk of the pane's process tree, and `Session.isAdopted` asks who launched the process, a different question from `isExternalCliMode()`, which is why the two are never folded together.
|
||||
|
||||
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it, and the `=` cannot go on `new-session -t` itself. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes.
|
||||
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it. The `=` cannot stand in for that guard on `new-session -t`, where a missing target makes a group literally called `=<name>`. ⚠️ The guard only holds for a name both commands read as a name, so a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`: a leading `=`, `@` or `%`, or a bare `~`) is refused at discovery: measured on tmux 3.4, `@1` joined the session holding window `@1` and input typed in the tab reached it, and `=eq` made a new group with a fresh shell. A `command -v tmux` runs first, because the guard fires on any failure and "tmux is not on this PATH" must not read as "your session is gone". The client is `tmux -u`, or a POSIX-locale container garbles the tab, and the probe's `list-panes` is too, or a name reads as underscores the guard then cannot find. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start: `startShell()`'s new-session `clear\n` submitted whatever the owner had half-typed (measured) until it was gated on `!this._adopt`, and the trust-dialog auto-accept stays off for the same reason. `shell` is also the classifier's answer for anything it cannot name, such as vim or psql.
|
||||
|
||||
**Names are the injection surface.** A session name is chosen by someone else and the local launch chain ends in `bash -c "<JSON>"`, where `$` and backticks stay live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
|
||||
**Names are the injection surface.** A session name is chosen by someone else and the local launch chain ends in `bash -c "<JSON>"`, where `$` and backticks stay live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. ⚠️ The attach line crosses `execSync` inside double quotes, so both launch sites use `quoteAdoptLaunch()`, never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on THIS host and a tmux in the remote `~/.local/bin` is found by discovery and missed by the attach. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
|
||||
|
||||
**A server that stops answering.** A tmux client never returns from a stopped server, and it hands dups of its stdout to the server, so that server holds our pipe open after the client is dead (measured: SIGTERM ended the client and no EOF came). ⚠️ So a watchdog around `list-panes` cannot work, and the same hold would keep `docker exec` and `ssh` waiting. `buildForeignProbeScript()` pings each socket with `has-session` at `/dev/null` under `FOREIGN_SOCKET_TIMEOUT_SECONDS`, in the script because no signal of ours reaches a client inside a container or across ssh, and only a server that answered gets the real stdout. One that did not prints `CMFH` and costs its own rows and a note. The watchdog reports through its own exit status, since a client ended by SIGTERM exits 0. `runForeignProbe()` runs all three transports in their own process group, signals the group at the deadline, destroys its end of the pipe by hand (or one handle leaks per scan) and keeps the rows it read. ⚠️ Sockets have no count cap: in glob order `codeman*` sorts before `default`, and a box with many instances would never reach the sessions this feature is for (212 sockets measured here). The sweep is bounded by time.
|
||||
|
||||
**One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach.
|
||||
|
||||
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response` and guarded input all do. The file surfaces (Files panel, File Viewer and its edit mode) still treat `/tmp` as the workspace, which is harmless: it is world-writable anyway, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
|
||||
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode) still treat `/tmp` as the workspace, which is harmless: it is world-writable anyway, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
|
||||
|
||||
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command.
|
||||
|
||||
|
||||
@@ -121,6 +121,13 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
|
||||
|
||||
## Attach to a container you already run
|
||||
|
||||
⚠️ Two different things are called "adopted". This section is about a CONTAINER
|
||||
(`DockerCase.owned === false`). A tmux SESSION a human started inside a container is
|
||||
listed by `GET /api/mux/foreign?docker=1` and wrapped by `POST /api/sessions/adopt`, for
|
||||
which the container needs a Docker case but no adoption of its own. That one is
|
||||
`Session.isAdopted`, with the connection on `Session.adopt.docker` and `session.docker`
|
||||
undefined. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
|
||||
|
||||
The tab's **Attach to an existing container** toggle points a case at a container **you**
|
||||
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
|
||||
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
|
||||
|
||||
@@ -176,6 +176,12 @@ when the tab closes.
|
||||
session. `remoteSessionName` holds its existing tmux name. Closing the tab
|
||||
**detaches**, never kills.
|
||||
|
||||
⚠️ This covers `codeman-*` sessions on the `codeman-remote` socket, the ones another
|
||||
Codeman runs. A tmux session a HUMAN started on the host is a separate path:
|
||||
`GET /api/mux/foreign?remote=1` finds it and `POST /api/sessions/adopt` wraps it, with
|
||||
the connection on `Session.adopt.remote` and `session.remote` undefined. Closing that tab
|
||||
detaches too. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
|
||||
|
||||
### Discovery
|
||||
|
||||
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
|
||||
|
||||
@@ -98,6 +98,10 @@ Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own con
|
||||
with one bind mount that has already been checked; an adopted container's mounts belong to
|
||||
whoever started it, and one that mounts `/` hands the adopter the host.
|
||||
|
||||
That is about a container. A tmux session someone started by hand inside a container is opened
|
||||
from the home screen instead, and it needs a Docker case pointing at that container: see
|
||||
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
|
||||
|
||||
## Credentials
|
||||
|
||||
Your existing host logins work inside the container without logging in again. Credentials
|
||||
|
||||
@@ -73,6 +73,10 @@ Attaching to someone else's session and closing your tab must not end their run,
|
||||
not. Several clients can attach the same remote session at different window sizes without
|
||||
clamping each other, and discovery shows a shared badge with the client count.
|
||||
|
||||
This list holds Codeman's own sessions. A tmux session someone started by hand on the host is
|
||||
found by the home screen instead: see
|
||||
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
|
||||
|
||||
## Files
|
||||
|
||||
Previews, downloads and text reads in a remote case go over the same ssh connection the
|
||||
|
||||
@@ -287,6 +287,38 @@ Two extras depending on the device:
|
||||
- **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then
|
||||
current sessions, then past ones. On by default.
|
||||
|
||||
### Opened outside Codeman
|
||||
|
||||
A tmux session you started by hand (`tmux new -s work`, then `claude`, or just a shell) shows
|
||||
up on the home screen under **Opened outside Codeman**, on the desktop and in the phone
|
||||
overview. **Open** wraps it in a tab, and **Go to tab** on a row means it already has one.
|
||||
|
||||
| Where it runs | How it is found |
|
||||
| -------------------- | ---------------------------------------------------------------------------- |
|
||||
| **This machine** | Automatically, every few seconds while the home screen is up. |
|
||||
| **A container** | The `⟳` button in the block's header scans the containers behind your Docker cases. |
|
||||
| **Over SSH** | The same button scans your saved remote hosts. |
|
||||
|
||||
The container and SSH scan costs one connection per target, so it runs when you press the
|
||||
button, and **Rescan** runs it again.
|
||||
|
||||
- **Closing the tab only detaches.** The session keeps running, and the close dialog offers no
|
||||
kill. Opening one types nothing into it.
|
||||
- **Codeman did not start it,** so it will not drive it: respawn, Ralph, auto-clear,
|
||||
auto-compact, auto-resume, custom model endpoints, prompt uploads and the image watcher are
|
||||
refused, and the git panel shows nothing.
|
||||
- **The tab opens on the session's first window,** and the row describes that same window.
|
||||
- **It joins like a second tmux client.** While the tab is open it can resize a session
|
||||
someone else is viewing, the way any second client does, and that reverses when it closes.
|
||||
- **A short line in the block says why a session is missing.** One whose name holds unusual
|
||||
characters is skipped, and so is one named so that tmux reads the name as something else (it
|
||||
starts with `=`, `@` or `%`, or is a bare `~`). Rename it with `tmux rename-session`. A tmux
|
||||
server that stopped answering costs only its own sessions.
|
||||
|
||||
Only sockets in tmux's own folder are scanned, so `tmux -L <name>` is found and
|
||||
`tmux -S /custom/path` is not. In multi-user mode the block is for admins, since it shows
|
||||
everyone's processes.
|
||||
|
||||
## Panels
|
||||
|
||||
| Panel | Opened from | Covered in |
|
||||
|
||||
@@ -233,11 +233,18 @@ export async function discoverLocalForeign(force = false): Promise<ForeignDiscov
|
||||
|
||||
localInFlight = (async (): Promise<ForeignDiscoveryResult> => {
|
||||
const notes: string[] = [];
|
||||
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
|
||||
// reach the inner sh intact (see @fileoverview).
|
||||
const run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
|
||||
const sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
|
||||
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
|
||||
let sessions: ForeignTmuxSession[] = [];
|
||||
try {
|
||||
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
|
||||
// reach the inner sh intact (see @fileoverview).
|
||||
const run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
|
||||
sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
|
||||
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
|
||||
} catch {
|
||||
// The probe answers rather than throws, so this is the classifier. "Nothing to
|
||||
// adopt here" is the only honest answer and it is never an error to the caller.
|
||||
notes.push('local: tmux probe produced no result');
|
||||
}
|
||||
const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes };
|
||||
localCache = { at: Date.now(), result };
|
||||
return result;
|
||||
@@ -322,18 +329,22 @@ export async function discoverDockerForeign(cases: DockerCase[]): Promise<Foreig
|
||||
await Promise.all(
|
||||
targets.map(async (t) => {
|
||||
const where = `docker ${t.containerName}`;
|
||||
const run = await runForeignProbe(foreignDockerProbeArgv(t));
|
||||
sessions.push(
|
||||
...candidatesFrom(
|
||||
run.stdout,
|
||||
'docker',
|
||||
`${t.hostId} ${t.containerName}`,
|
||||
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
|
||||
where,
|
||||
notes
|
||||
)
|
||||
);
|
||||
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
|
||||
try {
|
||||
const run = await runForeignProbe(foreignDockerProbeArgv(t));
|
||||
sessions.push(
|
||||
...candidatesFrom(
|
||||
run.stdout,
|
||||
'docker',
|
||||
`${t.hostId} ${t.containerName}`,
|
||||
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
|
||||
where,
|
||||
notes
|
||||
)
|
||||
);
|
||||
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
|
||||
} catch {
|
||||
notes.push(`${where}: not running, no tmux, or engine unreachable`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -79,11 +79,15 @@ export interface ForeignTmuxSession {
|
||||
attached: boolean;
|
||||
/** Epoch MILLISECONDS (tmux reports seconds; normalized on parse). */
|
||||
createdAt: number;
|
||||
/** What the active pane is running, from the bounded process-tree walk. */
|
||||
/**
|
||||
* What the session's FIRST LISTED pane is running, from the bounded process-tree
|
||||
* walk. Not the active window: claude in window 1 of a session whose window 0 holds
|
||||
* a shell reads as `shell`. The tab lands on that same first window, so the two agree.
|
||||
*/
|
||||
mode: SessionMode;
|
||||
/** Truncated command line behind the classification, for the UI subtitle. */
|
||||
command: string;
|
||||
/** The active pane's cwd. Informational — an adopted pane is never `cd`'d. */
|
||||
/** That same pane's cwd. Informational: an adopted pane is never `cd`'d. */
|
||||
workingDir: string;
|
||||
/** Codeman session id already wrapping this target, when one exists. */
|
||||
adoptedBy?: string;
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
|
||||
Reference in new issue
Block a user