diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md new file mode 100644 index 00000000..c24035d5 --- /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 6b2b337f..9654dd00 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 50133e8c..7e10e28a 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 50133e8c..7e10e28a 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 6a5fad47..6b78ab48 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 00000000..b1af3e88 --- /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`); + }); +});