From b2e96ddfafcb4747f3d4b37d032cfc0d83195211 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 20:41:47 -0400 Subject: [PATCH 1/4] feat(agent): COD-549 start workers in existing project directories --- .changeset/project-worker-directories.md | 5 + docs/wiki/Driving-Codeman-From-An-Agent.md | 8 ++ plugins/codeman/skills/codeman/SKILL.md | 6 + skills/codeman/SKILL.md | 6 + src/cli-agent.ts | 73 +++++++++-- test/cli-agent-project-spawn.test.ts | 141 +++++++++++++++++++++ 6 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 .changeset/project-worker-directories.md create mode 100644 test/cli-agent-project-spawn.test.ts diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md new file mode 100644 index 000000000..c24035d5b --- /dev/null +++ b/.changeset/project-worker-directories.md @@ -0,0 +1,5 @@ +--- +"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. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6b2b337f1..9654dd003 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -53,6 +53,14 @@ codeman agent ack # remove what you hand codeman agent rm "$SID" # any session except this one ``` +- **`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. - **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 diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 50133e8c9..7e10e28a8 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -41,6 +41,12 @@ 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. + ## 0. Guard and bootstrap If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 50133e8c9..7e10e28a8 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -41,6 +41,12 @@ 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. + ## 0. Guard and bootstrap If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad476..6b78ab48c 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -29,6 +29,8 @@ * * @module cli-agent */ +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; import http from 'node:http'; import https from 'node:https'; import type { Command } from 'commander'; @@ -509,7 +511,8 @@ export async function agentLs(deps: AgentDeps): Promise { } export interface SpawnOptions { - caseName: string; + caseName?: string; + workingDir?: string; mode: string; name?: string; /** Wait for the composer before returning, where the registry gives the mode a ready mark. */ @@ -532,23 +535,64 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl export function composerReadyMark(mode: string): string | undefined { return getCli(mode)?.capabilities.composerReadyMark; } -/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */ +/** `agent spawn` — start in a project directory or named case, preserving lineage and mode readiness. */ 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) + return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); + const workingDir = options.caseName ? undefined : resolve(options.workingDir ?? process.cwd()); + if (workingDir) { + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(workingDir)) + return fail(deps, 'Working directory contains control characters', EXIT.refused); + try { + if (!(await stat(workingDir)).isDirectory()) throw new Error('not a directory'); + } catch { + return fail(deps, 'Working directory must be an existing local directory', EXIT.refused); + } + } const body: Record = { - caseName: options.caseName, + ...(workingDir ? { workingDir } : { caseName: options.caseName }), mode: options.mode, parentSessionId: deps.ctx.selfId, }; - if (options.name) body.sessionName = options.name; + if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name; const res = await deps.request(deps.ctx, { method: 'POST', - path: '/api/v1/quick-start', + path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start', body, - headers: { ...AGENT_ORIGIN_HEADER }, + headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER }, }); - const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined; + const created = res.json?.data as + | { id?: string; session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } + | undefined; + const createdId = created?.session?.id ?? created?.id; + const data: + | { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean } + | undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created; if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res)); const sid = data.sessionId; + if (workingDir) { + // Creation alone leaves a session without a pane. Report the exact created id + // on an ambiguous or rejected start; never retry a start or hide a leaked tab. + let startupError: string | undefined; + try { + const started = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(sid)}/${cli.capabilities.startMode}`, + }); + if (!started.json?.success) startupError = describeFailure(started); + } catch (error) { + startupError = getErrorMessage(error) || 'Startup request failed'; + } + if (startupError) { + if (deps.json) emitJson(deps, { ...data, ready: false, startupError }); + else deps.io.out(sid); + deps.io.err(`Session ${sid} was created but startup failed or is ambiguous: ${startupError}`); + return EXIT.error; + } + } let ready: boolean | undefined; let readinessError: string | undefined; @@ -578,7 +622,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis } else { // Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone. const say = (line: string) => deps.io.err(line); - say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`)); + say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, ${workingDir ?? data.caseName ?? options.caseName})`)); if (ready === true) say(palette.muted(' composer up: the worker can take a prompt')); if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`)); if (ready === false && !dead) { @@ -1059,20 +1103,23 @@ export function registerAgentCommands(program: Command): Command { .action((options: { json?: boolean }) => run(Boolean(options.json), agentLs)); agent - .command('spawn ') - .description( - 'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one' - ) + .command('spawn [case]') + .description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported') .option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude') .option('-n, --name ', 'Session name shown in the UI') + .option('--working-dir ', 'Existing project directory (defaults to cwd when no case is given)') .option('--no-ready', 'Return as soon as the session exists, without the readiness wait') .option('-t, --timeout ', 'Readiness budget in ms', String(DEFAULT_WAIT_MS)) .option('--json', 'Machine-readable output') .action( - (caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) => + ( + caseName: string | undefined, + options: { mode: string; name?: string; workingDir?: string; ready: boolean; timeout?: string; json?: boolean } + ) => run(Boolean(options.json), (deps) => agentSpawn(deps, { caseName, + workingDir: options.workingDir, mode: options.mode, name: options.name, ready: options.ready, diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts new file mode 100644 index 000000000..b1af3e88b --- /dev/null +++ b/test/cli-agent-project-spawn.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + agentSpawn, + registerAgentCommands, + EXIT, + type AgentDeps, + type ApiResponse, + type RequestOptions, +} from '../src/cli-agent.js'; + +const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af'; +const OTHER = '94990c6d-e461-4a29-aa83-89275327732c'; +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); +const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' }); +function deps(responses: ApiResponse[]) { + const calls: RequestOptions[] = []; + const out: string[] = []; + const err: string[] = []; + const d: AgentDeps = { + ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' }, + json: true, + now: Date.now, + io: { out: (s) => out.push(s), err: (s) => err.push(s) }, + request: async (_ctx, options) => { + calls.push(options); + return responses.shift() ?? ok({}); + }, + }; + return { d, calls, out, err }; +} +const base = { mode: 'codex', ready: false, timeoutMs: 1000 }; +describe('project-preserving agent spawn', () => { + it('uses existing session create and interactive endpoints with exact directory and lineage', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod549-project-')); + dirs.push(directory); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok); + expect(t.calls).toHaveLength(2); + expect(t.calls[0]).toMatchObject({ + method: 'POST', + path: '/api/v1/sessions', + body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF }, + }); + expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin'); + expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` }); + 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({})]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); + }); + it('keeps explicit case quick-start behavior', async () => { + const t = deps([ok({ sessionId: OTHER })]); + expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok); + expect(t.calls).toHaveLength(1); + expect(t.calls[0].path).toBe('/api/v1/quick-start'); + }); + it('refuses case and working directory together before network IO', async () => { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('refuses missing directories, regular files and unknown modes before creating a session', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod549-project-')); + dirs.push(directory); + const file = join(directory, 'file'); + await writeFile(file, 'x'); + for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + } + }); + it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => { + const t = deps([ + ok({ id: OTHER }), + { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, + ]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false }); + expect(t.calls).toHaveLength(2); + }); + it('does not request startup when creation is refused', async () => { + const t = deps([{ status: 403, text: 'Forbidden' }]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + }); + it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => { + const t = deps([ok({ session: { id: OTHER } })]); + const request = t.d.request; + t.d.request = async (context, options) => { + if (options.path.endsWith('/interactive')) { + t.calls.push(options); + throw new Error('connection lost'); + } + return request(context, options); + }; + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out.join('\n'))).toMatchObject({ + sessionId: OTHER, + created: true, + startupError: 'connection lost', + }); + expect(t.calls).toHaveLength(2); + }); + it('treats an empty startup exception as a failure and keeps the created ID', async () => { + const t = deps([ok({ session: { id: OTHER } })]); + const request = t.d.request; + t.d.request = async (context, options) => { + if (options.path.endsWith('/interactive')) throw new Error(''); + return request(context, options); + }; + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out[0])).toMatchObject({ + sessionId: OTHER, + ready: false, + startupError: 'Startup request failed', + }); + }); + it('exposes an optional case and working directory in the CLI', () => { + const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!; + expect(spawn.registeredArguments[0].required).toBe(false); + 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({})]); + expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok); + expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`); + }); +}); From c5ffea0ed2d57ae77cf21aeaba98f24abd6756e5 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:39:16 -0400 Subject: [PATCH 2/4] 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 c24035d5b..8062be168 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 bb3c142c2..bec342ddf 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 95a28b178..9e0cbbc93 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 9654dd003..6797a1b7a 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 7e10e28a8..75f69edd4 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 3b99f200e..99db822e7 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 65af5c65f..d2e088ad7 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 7e10e28a8..75f69edd4 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 3b99f200e..99db822e7 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 65af5c65f..d2e088ad7 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 6b78ab48c..1e4b37fb0 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 b1af3e88b..dc19650d7 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); + }); }); From b26234d6c6d6eeaae4a2fd1e9e9b1f1a6e45e915 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:03:15 -0400 Subject: [PATCH 3/4] fix(agent): refuse an empty --working-dir instead of spawning in cwd `codeman agent spawn --working-dir "$WT"` with WT empty (a failed `git worktree add`, say) resolved '' to the caller's cwd and started an editing worker in the caller's own checkout. An empty or whitespace-only --working-dir now refuses with exit 4 before any request. The emitJson comment names the directory-spawn exception to "the envelope's data": `{ sessionId, workingDir, created: true, ready }`. Tests: unit (`''`, spaces, tab: exit 4, no calls) and through the real argument parser and HTTP transport (nothing sent, exit 4). Docs: README, api-reference, wiki, SKILL.md (plugin mirror synced) and the changeset note the refusal. --- .changeset/project-worker-directories.md | 2 +- README.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 3 ++- plugins/codeman/skills/codeman/SKILL.md | 7 ++++--- skills/codeman/SKILL.md | 7 ++++--- src/cli-agent.ts | 12 +++++++++++- test/cli-agent-project-spawn.test.ts | 15 +++++++++++++++ 8 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index 8062be168..2707b60e3 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 bec342ddf..35e62c72e 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 9e0cbbc93..f3651cb55 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 6797a1b7a..2b68c9259 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 75f69edd4..1433f58c5 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 75f69edd4..1433f58c5 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 1e4b37fb0..56a628530 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 dc19650d7..3a5be98fe 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); From 003398e209b9cd6ba1bb62e0dc6a82c44520f1ed Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 26 Oct 2026 08:51:15 -0400 Subject: [PATCH 4/4] fix(agent): refuse an implicit HOME cwd for a directory spawn `codeman agent spawn` always sends its directory as an explicit workingDir, so the server cannot apply its no-workingDir rule and installs workspace hooks (and an injected skill) into ~/.claude when the caller's cwd is HOME. A bare spawn whose cwd resolves to the home directory now refuses with exit 4 before any request and names `--working-dir ~`; an explicit `--working-dir ~` is still sent. Paths are compared by realpath, so a symlinked HOME matches, and an unresolvable HOME or cwd falls back to the resolved path without throwing. The directory-spawn --json shape now reads the same in the README, the API reference, the wiki page and the changeset: `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. A test pins all four. --- .changeset/project-worker-directories.md | 2 +- README.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 7 +- plugins/codeman/skills/codeman/SKILL.md | 3 +- skills/codeman/SKILL.md | 3 +- src/cli-agent.ts | 53 +++++++++++-- test/cli-agent-project-spawn.test.ts | 92 +++++++++++++++++++++- 8 files changed, 151 insertions(+), 13 deletions(-) diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index 2707b60e3..594ccef06 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, and an empty or whitespace-only `--working-dir` is refused locally (exit 4) before any request instead of resolving to the cwd. +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 }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `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. A cwd that resolves to the home directory is refused the same way, because the server would install its workspace hooks (and an injected skill) into `~/.claude`; an explicit `--working-dir ~` is still accepted. 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 35e62c72e..160f90326 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; 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. +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, and so does a cwd that is your home directory (Codeman's hooks would land in `~/.claude`), which needs an explicit `--working-dir ~`; 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 }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `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 f3651cb55..7366bdaa8 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, 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 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; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) 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 }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, 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 2b68c9259..bab92f3fc 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -59,6 +59,8 @@ codeman agent rm "$SID" # any session except t 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", as is an empty `--working-dir` (exit 4, before any request), so it never falls back to cwd. + A cwd that is your home directory is refused the same way, because Codeman's hooks would + land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose. 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 @@ -82,8 +84,9 @@ codeman agent rm "$SID" # any session except t 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`, except a directory - `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus - `startupError` when the start failed). + `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a + readiness wait ran, `readinessError` when that call failed, and `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 1433f58c5..f4ed3988c 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -46,7 +46,8 @@ For an existing project, use one command: 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`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 -rather than falling back to cwd. Directory launches keep parent lineage and never +rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would +land in `~/.claude`; pass `--working-dir ~` to mean it). 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. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 1433f58c5..f4ed3988c 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -46,7 +46,8 @@ For an existing project, use one command: 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`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 -rather than falling back to cwd. Directory launches keep parent lineage and never +rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would +land in `~/.claude`; pass `--working-dir ~` to mean it). 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. diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 56a628530..ca6155ccf 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -31,7 +31,8 @@ * * @module cli-agent */ -import { stat } from 'node:fs/promises'; +import { realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { resolve } from 'node:path'; import http from 'node:http'; import https from 'node:https'; @@ -378,9 +379,10 @@ export interface AgentDeps { /** * 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. + * except a directory spawn, which prints `{ sessionId, workingDir, created: true }` plus + * `ready` when a readiness wait ran, `readinessError` when that call failed, and + * `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)); @@ -542,6 +544,32 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl export function composerReadyMark(mode: string): string | undefined { return getCli(mode)?.capabilities.composerReadyMark; } + +/** realpath, or the plain resolved path when it cannot be resolved; never throws. */ +async function canonicalPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +/** + * Whether `directory` is the user's home directory, comparing realpaths so a symlinked + * HOME (or a cwd reached through a symlink) still matches. False when HOME is unknown. + */ +export async function isHomeDirectory(directory: string): Promise { + let home: string; + try { + home = homedir(); + } catch { + return false; + } + if (!home) return false; + const [a, b] = await Promise.all([canonicalPath(directory), canonicalPath(home)]); + return a === b; +} + /** `agent spawn` — start in a project directory or named case, preserving lineage and mode readiness. */ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise { const cli = getCli(options.mode); @@ -565,6 +593,18 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis } catch { return fail(deps, 'Working directory must be an existing local directory', EXIT.refused); } + // The server installs workspace hooks (and, with agent skill injection on, the + // skill) into the directory it is given, and it skips that only when the caller + // names no workingDir, because the cwd fallback is $HOME under installer-created + // services. This CLI always sends its directory explicitly, so an implicit cwd of + // HOME must be refused here or it would write ~/.claude/settings.local.json and + // ~/.claude/skills. An explicit `--working-dir ~` is the caller's choice and stays. + if (options.workingDir === undefined && (await isHomeDirectory(workingDir))) + return fail( + deps, + 'refusing: the current directory is your home directory; pass --working-dir ~ to start a worker there on purpose', + EXIT.refused + ); } const body: Record = { ...(workingDir ? { workingDir } : { caseName: options.caseName }), @@ -1122,7 +1162,10 @@ export function registerAgentCommands(program: Command): Command { .description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported') .option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude') .option('-n, --name ', 'Session name shown in the UI') - .option('--working-dir ', 'Existing project directory (defaults to cwd when no case is given)') + .option( + '--working-dir ', + 'Existing project directory (defaults to cwd when no case is given; a cwd of HOME is refused, pass it explicitly)' + ) .option('--no-ready', 'Return as soon as the session exists, without the readiness wait') .option('-t, --timeout ', 'Readiness budget in ms', String(DEFAULT_WAIT_MS)) .option('--json', 'Machine-readable output') diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index 3a5be98fe..4f76aac22 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -1,11 +1,13 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import http from 'node:http'; import { tmpdir } from 'node:os'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { agentSpawn, + isHomeDirectory, registerAgentCommands, EXIT, type AgentDeps, @@ -260,4 +262,92 @@ describe('agent spawn through the real argument parser and transport', () => { expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } }); expect(exitCode).toBe(EXIT.error); }); + it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir ` is sent', async () => { + const home = await mkdtemp(join(tmpdir(), 'cod549-home-')); + dirs.push(home); + vi.stubEnv('HOME', home); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home); + try { + const bare = await runSpawn([]); + expect(bare.seen).toEqual([]); + expect(bare.exitCode).toBe(EXIT.refused); + const explicit = await runSpawn(['--working-dir', home]); + expect(explicit.seen).toHaveLength(1); + expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } }); + } finally { + cwd.mockRestore(); + vi.unstubAllEnvs(); + } + }); +}); + +describe('agent spawn never treats an implicit HOME cwd as a project', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + async function homeDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'cod549-home-')); + dirs.push(directory); + return directory; + } + + it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~'); + }); + it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => { + const real = await homeDir(); + const link = join(await homeDir(), 'home-link'); + await symlink(real, link); + vi.stubEnv('HOME', link); + vi.spyOn(process, 'cwd').mockReturnValue(real); + const t = deps([]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: home }); + }); + it('allows a cwd below HOME', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + 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() }); + }); + it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => { + const missing = join(await homeDir(), 'absent'); + vi.stubEnv('HOME', missing); + await expect(isHomeDirectory(process.cwd())).resolves.toBe(false); + await expect(isHomeDirectory(missing)).resolves.toBe(true); + vi.stubEnv('HOME', ''); + await expect(isHomeDirectory(process.cwd())).resolves.toBe(false); + }); +}); + +describe('the directory-spawn --json shape is documented the same way everywhere', () => { + // The wiki wraps lines, so whitespace is normalized before comparing. + const clause = + '`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed'; + for (const file of [ + 'README.md', + 'docs/api-reference.md', + 'docs/wiki/Driving-Codeman-From-An-Agent.md', + '.changeset/project-worker-directories.md', + ]) { + it(file, () => { + expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause); + }); + } });