diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index 8062be16..2707b60e 100644 --- a/.changeset/project-worker-directories.md +++ b/.changeset/project-worker-directories.md @@ -2,6 +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. 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. +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, and an empty or whitespace-only `--working-dir` is refused locally (exit 4) before any request instead of resolving to the cwd. 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 bec342dd..35e62c72 100644 --- a/README.md +++ b/README.md @@ -995,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. `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. +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; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd; 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 9e0cbbc9..f3651cb5 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`, 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 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, or an empty or whitespace-only value, refuses with exit 4 before any request) 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 6797a1b7..2b68c925 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -57,7 +57,8 @@ 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, and an empty case - name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case". + name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case", + as is an empty `--working-dir` (exit 4, before any request), so it never falls back to cwd. 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 diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 75f69edd..1433f58c 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -45,9 +45,10 @@ 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. +`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 +rather than falling back to cwd. 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/SKILL.md b/skills/codeman/SKILL.md index 75f69edd..1433f58c 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -45,9 +45,10 @@ 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. +`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 +rather than falling back to cwd. 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/src/cli-agent.ts b/src/cli-agent.ts index 1e4b37fb..56a62853 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -376,7 +376,12 @@ export interface AgentDeps { now?: () => number; } -/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */ +/** + * Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy, + * except a directory spawn, which prints `{ sessionId, workingDir, created: true, ready }` + * (plus `startupError` when its start fails) because `POST /api/sessions` answers with + * the whole session row. + */ function emitJson(deps: AgentDeps, data: unknown): void { deps.io.out(JSON.stringify(data, null, 2)); } @@ -543,6 +548,11 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); if (options.caseName !== undefined && options.workingDir !== undefined) return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); + // Same rule for the flag: `--working-dir "$WT"` with WT empty (a failed + // `git worktree add`) must not fall through to the caller's own checkout, which + // `resolve('')` would make it. + if (options.workingDir !== undefined && options.workingDir.trim() === '') + return fail(deps, 'refusing: --working-dir is empty; pass an existing directory or omit the flag', EXIT.refused); // 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()); diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index dc19650d..3a5be98f 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -163,6 +163,14 @@ describe('project-preserving agent spawn', () => { expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused); expect(t.calls).toEqual([]); }); + it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => { + for (const workingDir of ['', ' ', '\t']) { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + expect(t.out.join('\n')).toContain('--working-dir is empty'); + } + }); 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); @@ -239,6 +247,13 @@ describe('agent spawn through the real argument parser and transport', () => { expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } }); expect(exitCode).toBe(EXIT.error); }); + it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => { + for (const value of ['', ' ']) { + const { seen, exitCode } = await runSpawn(['--working-dir', value]); + expect(seen).toEqual([]); + expect(exitCode).toBe(EXIT.refused); + } + }); it('`spawn` with no case argument creates in the caller cwd', async () => { const { seen, exitCode } = await runSpawn([]); expect(seen).toHaveLength(1);