From c5ffea0ed2d57ae77cf21aeaba98f24abd6756e5 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:39:16 -0400 Subject: [PATCH] fix(agent): treat only an omitted case as a directory spawn, and document the directory launch `codeman agent spawn ""` (for example `spawn "$CASE"` with CASE unset) used to launch a worker in the caller's cwd. Both case checks now test `caseName !== undefined`, so an empty name reaches quick-start and is refused by its schema as before. Drop the `{ id }` fallback on the create answer: `POST /api/sessions` answers `{ session: { id } }`, and the test fakes now use that shape. Tests: an empty case name (unit and through the real argument parser and HTTP transport), the claude `--ready` order create, /interactive, wait-output on the created id, and a non-JSON failed start (id alone on stdout, reason on stderr, exit 1). Docs: the spawn mapping and directory-launch JSON shape in docs/api-reference.md, the README and wiki `codeman agent` sections, the cli-agent @fileoverview and the skill reference tables; reflow the new SKILL.md paragraph (plugin mirror synced); the changeset notes the local exit-4 refusal of an unknown mode for named cases. --- .changeset/project-worker-directories.md | 4 +- README.md | 3 +- docs/api-reference.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 20 +-- plugins/codeman/skills/codeman/SKILL.md | 12 +- .../skills/codeman/reference/messaging.md | 2 +- .../codeman/skills/codeman/reference/verbs.md | 2 +- skills/codeman/SKILL.md | 12 +- skills/codeman/reference/messaging.md | 2 +- skills/codeman/reference/verbs.md | 2 +- src/cli-agent.ts | 19 +-- test/cli-agent-project-spawn.test.ts | 115 +++++++++++++++++- 12 files changed, 160 insertions(+), 35 deletions(-) diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index c24035d5..8062be16 100644 --- a/.changeset/project-worker-directories.md +++ b/.changeset/project-worker-directories.md @@ -2,4 +2,6 @@ "aicodeman": minor --- -Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. +Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed, exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it. + +One change for named-case spawns too: an unknown `--mode` is now refused locally (exit 4) before any request, where it used to reach quick-start and come back as the server's 400 (exit 1). diff --git a/README.md b/README.md index bb3c142c..bec342dd 100644 --- a/README.md +++ b/README.md @@ -982,6 +982,7 @@ The skill above drives the API with `curl` and `jq` from a preamble Codeman seed ```bash codeman agent ls # sessions; * marks this one SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark +SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default) codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) @@ -994,7 +995,7 @@ codeman agent ack # remove what you hand codeman agent rm "$SID" # any session except this one ``` -Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed). `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. ### MCP server (`codeman mcp`): drive Codeman from any MCP client diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b17..9e0cbbc9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -591,7 +591,7 @@ request returns. The capture runs synchronous tmux calls on the server; the ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn ` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir ` (an existing local directory; combining it with a case refuses) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready, readinessError }`, and on a failed or ambiguous start `{ sessionId, workingDir, created: true, ready: false, startupError }` (exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 9654dd00..6797a1b7 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -41,6 +41,7 @@ act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"oth ```bash codeman agent ls # sessions; * marks this one SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark +SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default) codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) @@ -55,12 +56,15 @@ codeman agent rm "$SID" # any session except t - **`spawn [case]`** uses the current directory when no case is supplied. Use `codeman agent spawn --working-dir /path/to/project --mode codex` for another existing - local directory. A case and `--working-dir` cannot be combined. Explicit case names - retain quick-start behavior (creating a scratch case if missing). Directory launches - preserve the project and parent lineage, and never label it as agent scratch. Choose - a separate worktree for each worker that will edit code. If startup fails or its - response is lost, the CLI reports the exact created session ID; inspect that session - before deciding what to do next. + local directory. A case and `--working-dir` cannot be combined, and an empty case + name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case". + Explicit case names retain quick-start behavior (creating a scratch case if missing). + A directory launch is `POST /api/v1/sessions` followed by + `POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and + parent lineage, and never labels it as agent scratch. Choose a separate worktree for + each worker that will edit code. If startup fails or its response is lost, the CLI + reports the exact created session ID and exits `1`; inspect that session before + deciding what to do next. - **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an ambiguous prefix. - **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that @@ -76,7 +80,9 @@ codeman agent rm "$SID" # any session except t **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), and goes when its session is deleted. - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the - worker exited, `4` refused. `--json` prints the response's `data`. + worker exited, `4` refused. `--json` prints the response's `data`, except a directory + `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus + `startupError` when the start failed). ## The MCP server diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 7e10e28a..75f69edd 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -41,11 +41,13 @@ verbs over the same endpoints, with the guards below (no control bytes, no self- no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. -For an existing project, use `codeman agent spawn --working-dir /path/to/project --mode codex` (one command). With no case or `--working-dir`, `spawn` uses cwd. Give -editing workers separate worktrees. A positional case keeps quick-start behavior; -it cannot be combined with `--working-dir`. Directory launches keep parent lineage -and never mark the project as agent scratch. If startup fails or is ambiguous, the -CLI reports the exact created ID: inspect it before retrying or cleaning up. +For an existing project, use one command: +`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and +no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A +positional case keeps quick-start behavior and cannot be combined with +`--working-dir`. Directory launches keep parent lineage and never mark the project +as agent scratch. If startup fails or is ambiguous, the CLI reports the exact +created ID: inspect it before retrying or cleaning up. ## 0. Guard and bootstrap diff --git a/plugins/codeman/skills/codeman/reference/messaging.md b/plugins/codeman/skills/codeman/reference/messaging.md index 3b99f200..99db822e 100644 --- a/plugins/codeman/skills/codeman/reference/messaging.md +++ b/plugins/codeman/skills/codeman/reference/messaging.md @@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | Job | Channel | | --- | --- | -| spawn a worker, create its case | HTTP `quick-start` (the only path) | +| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) | | readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) | | deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input | | steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) | diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 65af5c65..d2e088ad 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | | a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 7e10e28a..75f69edd 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -41,11 +41,13 @@ verbs over the same endpoints, with the guards below (no control bytes, no self- no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. -For an existing project, use `codeman agent spawn --working-dir /path/to/project --mode codex` (one command). With no case or `--working-dir`, `spawn` uses cwd. Give -editing workers separate worktrees. A positional case keeps quick-start behavior; -it cannot be combined with `--working-dir`. Directory launches keep parent lineage -and never mark the project as agent scratch. If startup fails or is ambiguous, the -CLI reports the exact created ID: inspect it before retrying or cleaning up. +For an existing project, use one command: +`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and +no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A +positional case keeps quick-start behavior and cannot be combined with +`--working-dir`. Directory launches keep parent lineage and never mark the project +as agent scratch. If startup fails or is ambiguous, the CLI reports the exact +created ID: inspect it before retrying or cleaning up. ## 0. Guard and bootstrap diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 3b99f200..99db822e 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | Job | Channel | | --- | --- | -| spawn a worker, create its case | HTTP `quick-start` (the only path) | +| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) | | readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) | | deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input | | steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) | diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 65af5c65..d2e088ad 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | | a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6b78ab48..1e4b37fb 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -2,9 +2,11 @@ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running * inside a Codeman session, in every CLI mode. * - * A thin HTTP client over endpoints that already exist (`quick-start`, `input`, - * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the - * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It + * A thin HTTP client over endpoints that already exist (`quick-start` for a named + * case, `POST sessions` + `sessions/:id/interactive|shell` for a worker in an + * existing directory, `input`, `wait`, `wait-output`, `last-response`, `terminal`, + * `DELETE sessions/:id`, and the agent inbox: `inbox`, `inbox/ack`, + * `agent-inbox/summary`). It * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is * the packaged agent skill's (`skills/codeman`), ported from shell prose into code @@ -539,9 +541,11 @@ export function composerReadyMark(mode: string): string | undefined { export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise { const cli = getCli(options.mode); if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); - if (options.caseName && options.workingDir !== undefined) + if (options.caseName !== undefined && options.workingDir !== undefined) return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); - const workingDir = options.caseName ? undefined : resolve(options.workingDir ?? process.cwd()); + // Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with + // CASE unset) still goes to quick-start, whose schema refuses it. + const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd()); if (workingDir) { // eslint-disable-next-line no-control-regex if (/[\x00-\x1f\x7f]/.test(workingDir)) @@ -565,9 +569,10 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER }, }); const created = res.json?.data as - | { id?: string; session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } + | { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } | undefined; - const createdId = created?.session?.id ?? created?.id; + // `POST /api/sessions` answers `{ session: { id, ... } }`; quick-start answers `{ sessionId, ... }`. + const createdId = created?.session?.id; const data: | { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean } | undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created; diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index b1af3e88..dc19650d 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -1,8 +1,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Command } from 'commander'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { agentSpawn, registerAgentCommands, @@ -53,7 +54,7 @@ describe('project-preserving agent spawn', () => { expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory }); }); it('defaults to the current directory when no case is supplied', async () => { - const t = deps([ok({ id: OTHER }), ok({})]); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); }); @@ -81,7 +82,7 @@ describe('project-preserving agent spawn', () => { }); it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => { const t = deps([ - ok({ id: OTHER }), + ok({ session: { id: OTHER } }), { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, ]); expect(await agentSpawn(t.d, base)).toBe(EXIT.error); @@ -134,8 +135,114 @@ describe('project-preserving agent spawn', () => { expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true); }); it('starts a shell through its registered startMode endpoint', async () => { - const t = deps([ok({ id: OTHER }), ok({})]); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok); expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`); }); + it('a create answer without session.id is a failure, and nothing is started', async () => { + // `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route. + const t = deps([ok({ id: OTHER })]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + }); + it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => { + const t = deps([ + { + status: 400, + text: '', + json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' }, + }, + ]); + expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } }); + expect(t.calls[0].body).not.toHaveProperty('workingDir'); + }); + it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => { + const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]); + expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok); + expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + 'POST /api/v1/sessions', + `POST /api/v1/sessions/${OTHER}/interactive`, + `GET /api/v1/sessions/${OTHER}/wait-output`, + ]); + expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' }); + expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true }); + }); + it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => { + const t = deps([ + ok({ session: { id: OTHER } }), + { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, + ]); + t.d.json = false; + expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error); + expect(t.out).toEqual([OTHER]); + expect(t.err).toHaveLength(1); + expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`); + expect(t.err[0]).toContain('cannot start'); + expect(t.calls).toHaveLength(2); // no readiness wait after a failed start + }); +}); + +describe('agent spawn through the real argument parser and transport', () => { + async function runSpawn(args: string[]) { + const seen: Array<{ method?: string; url?: string; body: unknown }> = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined }); + res + .writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' })); + }); + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const { port } = server.address() as { port: number }; + const saved = { ...process.env }; + const savedExit = process.exitCode; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + Object.assign(process.env, { + CODEMAN_MUX: '1', + CODEMAN_API_URL: `http://127.0.0.1:${port}`, + CODEMAN_SESSION_ID: SELF, + }); + delete process.env.CODEMAN_PASSWORD; + const program = new Command(); + program.exitOverride(); + registerAgentCommands(program); + await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']); + return { seen, exitCode: process.exitCode }; + } finally { + process.exitCode = savedExit; + for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + log.mockRestore(); + error.mockRestore(); + await new Promise((done) => server.close(() => done())); + } + } + + it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => { + const { seen, exitCode } = await runSpawn(['']); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } }); + expect(exitCode).toBe(EXIT.error); + }); + it('`spawn` with no case argument creates in the caller cwd', async () => { + const { seen, exitCode } = await runSpawn([]); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } }); + expect(exitCode).toBe(EXIT.error); + }); });