diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index 2707b60e..594ccef0 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 35e62c72..160f9032 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 f3651cb5..7366bdaa 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 2b68c925..bab92f3f 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 1433f58c..f4ed3988 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 1433f58c..f4ed3988 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 56a62853..ca6155cc 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 3a5be98f..4f76aac2 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); + }); + } });