/** * @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`). 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 * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only * preamble — has the same verbs from one line in its AGENTS.md. * * Invariants (each asserted in `test/cli-agent.test.ts`): * 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it * never guesses a URL — a server you are not part of is not yours to drive. * 2. `send` transmits printable text plus `\r` only. ESC exists solely as * `interrupt`, which never appends `\r`. A stray control byte is a dead session * in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`). * 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER * direction refuses. Ids appear in full and 8-char form, so equality alone * misses a real combination — and the miss deletes the caller. * 4. An id shorter than 8 characters refuses (exit 4) before any request, on every * verb. `9` would resolve to whichever session is alone with that first * character, the user's own interactive tab included. * * Commands live here as functions returning an exit code, not calling * `process.exit`, so the whole surface is unit-testable against a fake server. * * @module cli-agent */ import http from 'node:http'; import https from 'node:https'; import type { Command } from 'commander'; import { basicAuthHeader, credentialsFrom, readCodemanEnvFile, type CodemanCredentials, } from './codeman-credentials.js'; import { getCli } from './config/cli-registry/registry.js'; import { GLYPH, palette, table } from './cli-style.js'; import { getErrorMessage } from './types.js'; import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js'; // ───────────────────────────────────────────────────────────────────────────── // Context and guard // ───────────────────────────────────────────────────────────────────────────── export interface AgentContext { /** Base URL of the Codeman server, from `CODEMAN_API_URL`. */ apiUrl: string; /** This session's id, from `CODEMAN_SESSION_ID`. */ selfId: string; /** Basic-auth credentials, when the server has a password. */ auth?: CodemanCredentials; } /** Thrown when the process is not inside a Codeman-managed session. */ export class AgentGuardError extends Error {} /** Exit codes shared by every verb; a shell agent can branch on them. */ export const EXIT = { ok: 0, error: 1, timeout: 2, dead: 3, refused: 4, } as const; /** * Resolve the context from the environment, or throw `AgentGuardError`. * * Credentials in the order every client of the API uses (`credentialsFrom`, shared * with `codeman attach` and the TUI): each field from the environment (a session * inherits the server's), then the data dir's `.env`. No password means the server * is open (single-user) — or it is not, and the 401 says so. */ export function resolveAgentContext( env: NodeJS.ProcessEnv = process.env, envFile: () => Record = readCodemanEnvFile ): AgentContext { if (env.CODEMAN_MUX !== '1') { throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.'); } const apiUrl = env.CODEMAN_API_URL?.trim(); if (!apiUrl) { throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.'); } const selfId = env.CODEMAN_SESSION_ID?.trim(); if (!selfId) { throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.'); } const credentials = credentialsFrom(env, envFile()); return { apiUrl, selfId, auth: credentials.password ? credentials : undefined }; } // ───────────────────────────────────────────────────────────────────────────── // Pure helpers (the invariants) // ───────────────────────────────────────────────────────────────────────────── /** * Is `id` this session? Prefix in BOTH directions, because ids appear in full and * in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id * shorter than 8 characters cannot prove anything and is treated as "maybe me". */ export function isSelfSession(selfId: string, id: string): boolean { if (!id || selfId.length < 8) return true; return id.startsWith(selfId) || selfId.startsWith(id); } /** Why `rm` refuses, or `undefined` when the delete may go ahead. */ export function deleteRefusal(selfId: string, id: string): string | undefined { if (!id) return 'refusing: empty session id'; if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me'; if (isSelfSession(selfId, id)) return `refusing: ${id} is me`; return undefined; } /** * The prompt `send` was given, which must be ONE argument. Joining several with spaces * would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline, * back into a single line, so the multi-line refusal below would never see it. */ export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } { if (words.length === 1) return { text: words[0] }; return { error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send "…"\`; a prompt that starts with "-" goes after --: \`send -- "- fix the bug"\`)`, }; } /** * Why `send` refuses this text, or `undefined` when it is printable. The composer * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and * DEL) reaches the pane as a keypress. None of that is a prompt. */ export function inputRefusal(text: string): string | undefined { if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send ""` is never a prompt)'; // The composer is one line: the server strips newlines, which silently joins the // lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle). if (/[\n\r\t]/.test(text)) { return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path'; } // C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal). // eslint-disable-next-line no-control-regex const control = text.match(/[\x00-\x1f\x7f-\x9f]/); if (control) { const code = control[0].charCodeAt(0).toString(16).padStart(2, '0'); return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`; } return undefined; } /** * Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the * caller asked to type without submitting. Never anything else. */ export function buildSendBody( text: string, options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number } ): Record { const body: Record = { input: options.enter ? `${text}\r` : text, useMux: true, clientId: options.clientId, seq: options.seq, }; if (options.wait !== undefined) body.wait = options.wait; if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout; return body; } /** Body for the interrupt path: a bare ESC, and nothing appended — ever. */ export function buildInterruptBody(clientId: string, seq: number): Record { return { input: '\u001b', useMux: true, clientId, seq }; } /** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */ export function defaultClientId(selfId: string, suffix = ''): string { return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`; } /** * Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window * titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in. */ export function stripAnsi(text: string): string { // eslint-disable-next-line no-control-regex return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, ''); } /** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */ export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number { if (raw === undefined) return fallback; const n = Number(raw); if (!Number.isInteger(n) || n <= 0) { throw new Error(`${flag} must be a positive integer, got "${raw}"`); } return n; } /** * Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead, * timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies * during `--until stop` comes back as `ended:true, signal:null` (the registry only * satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on * a dead worker as `ended:true, matched:false` — both are "dead", never "done". */ export function waitExitCode(wait: WaitResult | undefined): number { if (!wait) return EXIT.error; if (wait.signal === 'exit' || wait.ended) return EXIT.dead; if (wait.timedOut) return EXIT.timeout; if (wait.matched === false) return EXIT.timeout; return EXIT.ok; } // ───────────────────────────────────────────────────────────────────────────── // HTTP // ───────────────────────────────────────────────────────────────────────────── export interface ApiEnvelope { success: boolean; data?: T; error?: string; errorCode?: string; } export interface ApiResponse { status: number; /** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */ json?: ApiEnvelope; text: string; } export interface WaitResult { /** The signal that fired, or null when the wait ended without one. */ signal?: string | null; timedOut?: boolean; /** The session went away (deleted / torn down / the write failed) before the wait resolved. */ ended?: boolean; timeoutMs?: number; until?: string[]; matched?: boolean; match?: string; snippet?: string; immediate?: boolean; } export interface RequestOptions { method: 'GET' | 'POST' | 'DELETE'; path: string; query?: Record; body?: Record; headers?: Record; /** Socket timeout; long-polls pass their own timeout plus headroom. */ timeoutMs?: number; /** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */ signal?: AbortSignal; } export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise; /** Every request carries these; they are ignored on endpoints that do not read them. */ export function baseHeaders(ctx: AgentContext): Record { const headers: Record = { Accept: 'application/json' }; // Tags sessions this caller spawns as its children (lineage in the web UI). // Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case // directory as deletable agent scratch, so it rides only the spawn request that // may create one (see agentSpawn), never anything else. Absent for a caller outside // any session (`codeman mcp` launched by a desktop MCP client), which has no self. if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId; const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined; if (authorization) headers.Authorization = authorization; return headers; } /** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */ export const httpRequest: ApiRequest = (ctx, options) => { const url = new URL(options.path, ctx.apiUrl); for (const [key, value] of Object.entries(options.query ?? {})) { if (value !== undefined) url.searchParams.set(key, String(value)); } const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body); const headers: Record = { ...baseHeaders(ctx), ...(options.headers ?? {}) }; if (bodyText !== undefined) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(bodyText); } const transport = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = transport.request( { protocol: url.protocol, hostname: url.hostname, port: url.port, method: options.method, path: `${url.pathname}${url.search}`, rejectUnauthorized: false, headers, timeout: options.timeoutMs ?? 30_000, signal: options.signal, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); // A connection dropped mid-body emits 'error' and never 'end'; without this the // promise never settles (and a long-lived `codeman mcp` never answers the call). res.on('error', reject); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf-8'); let json: ApiEnvelope | undefined; try { json = JSON.parse(text) as ApiEnvelope; } catch { json = undefined; } resolve({ status: res.statusCode ?? 0, json, text }); }); } ); req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`))); req.on('error', reject); if (bodyText !== undefined) req.write(bodyText); req.end(); }); }; /** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */ export function describeFailure(res: ApiResponse): string { if (res.json && !res.json.success) { return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`; } const text = res.text.trim().split('\n')[0] ?? ''; if (res.status === 401) return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`; return `HTTP ${res.status}${text ? ` ${text}` : ''}`; } // ───────────────────────────────────────────────────────────────────────────── // Commands // ───────────────────────────────────────────────────────────────────────────── export interface AgentIo { out: (line: string) => void; err: (line: string) => void; } export interface AgentDeps { ctx: AgentContext; request: ApiRequest; io: AgentIo; json: boolean; /** Clock for `seq`; injectable so tests are deterministic. */ now?: () => number; } /** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */ function emitJson(deps: AgentDeps, data: unknown): void { deps.io.out(JSON.stringify(data, null, 2)); } function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number { if (deps.json) { deps.io.out(JSON.stringify({ success: false, error: message })); } else { deps.io.err(palette.err(`${GLYPH.fail} ${message}`)); } return code; } interface SessionRow { id: string; name?: string; mode?: string; status?: string; workingDir?: string; pid?: number | null; parentSessionId?: string | null; } /** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */ const FULL_ID_LENGTH = 36; /** * Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor * the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts). */ export const MIN_ID_PREFIX_LENGTH = 8; /** * Turn the id a human typed into the one the routes accept. `ls` prints 8-char * prefixes and the routes answer 404 to those (measured live), so anything shorter * than a full id resolves through the session list; an ambiguous prefix refuses * rather than picking one. Below 8 characters it refuses before the list: "unique" * means nothing for `9` — it names whatever session happens to be alone with that * first character, and `rm`/`send` would act on it. */ export async function resolveSessionId( deps: AgentDeps, id: string ): Promise<{ id: string } | { error: string; code: number }> { if (!id) return { error: 'refusing: empty session id', code: EXIT.refused }; if (id.length < MIN_ID_PREFIX_LENGTH) { return { error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`, code: EXIT.refused, }; } if (id.length >= FULL_ID_LENGTH) return { id }; const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }); if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error }; const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id)); if (matches.length === 1) return { id: matches[0].id }; if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error }; return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error }; } /** `agent ls` — every session the caller can see, self marked. */ export async function agentLs(deps: AgentDeps): Promise { const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }); if (!res.json?.success) return fail(deps, describeFailure(res)); const sessions = (res.json.data as SessionRow[] | undefined) ?? []; if (deps.json) { emitJson( deps, sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) })) ); return EXIT.ok; } if (sessions.length === 0) { deps.io.out(palette.muted('(no sessions)')); return EXIT.ok; } const rows = sessions.map((s) => [ isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ', s.id.slice(0, 8), s.mode ?? '?', s.status ?? '?', s.name || s.workingDir || '', ]); deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 })); deps.io.out( palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`) ); return EXIT.ok; } export interface SpawnOptions { caseName: string; mode: string; name?: string; /** Wait for the composer before returning, where the registry gives the mode a ready mark. */ ready: boolean; timeoutMs: number; } /** * Agent-scratch label for a case directory a spawn CREATES (the server applies it only * when quick-start makes the directory). The Add Case UI offers a recursive delete for * such directories, so this header must never ride any other request: mislabelling a * real repo there is the one failure in this area that costs actual work. */ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const; /** * What the mode's TUI draws once its composer can take a prompt, from the CLI registry * (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait. */ 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. */ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise { const body: Record = { caseName: options.caseName, mode: options.mode, parentSessionId: deps.ctx.selfId, }; if (options.name) body.sessionName = options.name; const res = await deps.request(deps.ctx, { method: 'POST', path: '/api/v1/quick-start', body, headers: { ...AGENT_ORIGIN_HEADER }, }); const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined; if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res)); const sid = data.sessionId; let ready: boolean | undefined; let readinessError: string | undefined; let dead = false; const mark = composerReadyMark(options.mode); if (options.ready && mark) { const wait = await deps.request(deps.ctx, { method: 'GET', path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, timeoutMs: options.timeoutMs + 10_000, }); // A failed readiness call (waiter cap, 400, network) is its own error, not "the // composer never showed up": report the real reason instead of the trust-dialog hint. if (!wait.json?.success) readinessError = describeFailure(wait); else { const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; // A worker that died while we waited is exit 3 like every other wait, not a // "composer not seen" timeout that sends the caller looking for a dialog. dead = waitExitCode(result) === EXIT.dead; ready = !dead && Boolean(result?.matched); } } if (deps.json) { emitJson(deps, { ...data, ready, readinessError }); } 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})`)); 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) { say( palette.warn( `${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)` ) ); } if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`)); if (ready === undefined && !readinessError && options.ready) { say( palette.muted( ` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize` ) ); } deps.io.out(sid); } if (readinessError) return EXIT.error; if (dead) return EXIT.dead; return ready === false ? EXIT.timeout : EXIT.ok; } export interface SendOptions { id: string; text: string; enter: boolean; /** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */ wait?: string | true; timeoutMs?: number; clientId?: string; seq?: number; } /** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise { if (isSelfSession(deps.ctx.selfId, options.id)) { return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused); } const refusal = inputRefusal(options.text); if (refusal) return fail(deps, refusal, EXIT.refused); const target = await resolveSessionId(deps, options.id); if ('error' in target) return fail(deps, target.error, target.code); const body = buildSendBody(options.text, { enter: options.enter, clientId: options.clientId ?? defaultClientId(deps.ctx.selfId), seq: options.seq ?? (deps.now ?? Date.now)(), wait: options.wait, waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined, }); const res = await deps.request(deps.ctx, { method: 'POST', path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`, body, timeoutMs: (options.timeoutMs ?? 60_000) + 10_000, }); if (!res.json?.success) return fail(deps, describeFailure(res)); const data = res.json.data as | { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult } | undefined; if (deps.json) emitJson(deps, data ?? {}); // Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server // holds the chunk and types it once the pane is back (`buffered`), or the chunk was // over the wake buffer's cap and is gone (`dropped`). The seq is spent either way, // so a retry needs a new one (the default, the clock, gives it that). if (data?.dropped) { if (!deps.json) { deps.io.err( palette.err( `${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back` ) ); } return EXIT.error; } // `delivered:false` without `duplicate` is the route's "the bytes went nowhere": // the PTY exited or send-keys hit a dead pane. The field exists so a client does not // say "wait longer" when the truth is "restart the worker" — so it is a failure here. if (data?.delivered === false && !data.duplicate) { if (!deps.json) { deps.io.err( palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`) ); } return EXIT.dead; } if (!deps.json) { const noEnter = options.enter ? '' : ' (no Enter)'; if (data?.duplicate) { deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`)); } else if (data?.buffered) { deps.io.out( palette.ok( `${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back` ) ); } else if (data?.delivered === true) { deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`)); } else { // Fire-and-forget answers before the write, so there is no delivery report here. deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`)); } if (data?.wait) deps.io.out(describeWait(data.wait)); } if (options.wait === undefined) return EXIT.ok; return waitExitCode(data?.wait); } function describeWait(wait: WaitResult): string { if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`); if (wait.ended) { return palette.err( `${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)` ); } // A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it, // so the line stays neutral instead of looking like an error to whoever reads the log. if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`); if (wait.matched !== undefined) { return wait.matched ? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`) : palette.muted('not matched (exit 2)'); } return palette.ok( `${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}` ); } export interface WaitOptions { id: string; until?: string; match?: string; from?: 'buffer' | 'now'; fresh?: boolean; nocase?: boolean; timeoutMs: number; } /** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise { if (options.until && options.match) return fail(deps, 'use either --until or --match , not both', EXIT.refused); const target = await resolveSessionId(deps, options.id); if ('error' in target) return fail(deps, target.error, target.code); const sid = encodeURIComponent(target.id); const res = options.match ? await deps.request(deps.ctx, { method: 'GET', path: `/api/v1/sessions/${sid}/wait-output`, query: { match: options.match, from: options.from ?? 'buffer', nocase: options.nocase ? 1 : undefined, timeout: options.timeoutMs, }, timeoutMs: options.timeoutMs + 10_000, }) : await deps.request(deps.ctx, { method: 'GET', path: `/api/v1/sessions/${sid}/wait`, query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs }, timeoutMs: options.timeoutMs + 10_000, }); // A 400 here is the server saying "this mode has no such signal" (until=stop on an // external CLI). Passed through, never papered over: the marker path is the answer. if (!res.json?.success) return fail(deps, describeFailure(res)); const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined; if (deps.json) { emitJson(deps, data ?? {}); } else if (data?.wait) { deps.io.out(describeWait(data.wait)); if (data.limitPaused) deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`)); } return waitExitCode(data?.wait); } export interface ReadOptions { id: string; /** Bytes of raw terminal to fetch; ANSI is stripped for humans. */ tail?: number; /** Whole conversation (`context=full`) instead of the last assistant message. */ full?: boolean; } /** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */ export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise { const target = await resolveSessionId(deps, options.id); if ('error' in target) return fail(deps, target.error, target.code); const sid = encodeURIComponent(target.id); if (options.tail !== undefined) { const res = await deps.request(deps.ctx, { method: 'GET', path: `/api/v1/sessions/${sid}/terminal`, query: { tail: options.tail }, }); if (!res.json?.success) return fail(deps, describeFailure(res)); const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? ''; if (deps.json) emitJson(deps, res.json.data); else deps.io.out(stripAnsi(buffer)); return EXIT.ok; } const res = await deps.request(deps.ctx, { method: 'GET', path: `/api/v1/sessions/${sid}/last-response`, query: { context: options.full ? 'full' : undefined }, }); if (!res.json?.success) return fail(deps, describeFailure(res)); const data = res.json.data as | { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> } | undefined; if (deps.json) { emitJson(deps, data ?? {}); return EXIT.ok; } if (options.full && data?.messages) { for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`); return EXIT.ok; } const text = data?.text ?? ''; if (!text) { deps.io.err( palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)') ); return EXIT.ok; } deps.io.out(text); return EXIT.ok; } /** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */ export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise { if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); const target = await resolveSessionId(deps, options.id); if ('error' in target) return fail(deps, target.error, target.code); const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)()); const res = await deps.request(deps.ctx, { method: 'POST', path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`, body, }); if (!res.json?.success) return fail(deps, describeFailure(res)); if (deps.json) emitJson(deps, res.json.data ?? {}); else deps.io.out( palette.ok( `${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt` ) ); return EXIT.ok; } /** `agent rm` — delete a session that is provably not this one. */ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise { const refusal = deleteRefusal(deps.ctx.selfId, options.id); if (refusal) return fail(deps, refusal, EXIT.refused); const target = await resolveSessionId(deps, options.id); if ('error' in target) return fail(deps, target.error, target.code); // The guard again on the RESOLVED id: a prefix that is not me can still resolve // to me only if the list is lying, but a delete is the one call worth the paranoia. const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id); if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused); const res = await deps.request(deps.ctx, { method: 'DELETE', path: `/api/v1/sessions/${encodeURIComponent(target.id)}`, }); if (!res.json?.success) return fail(deps, describeFailure(res)); if (deps.json) emitJson(deps, res.json.data ?? {}); else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`)); return EXIT.ok; } // ───────────────────────────────────────────────────────────────────────────── // Commander wiring // ───────────────────────────────────────────────────────────────────────────── const DEFAULT_WAIT_MS = 60_000; /** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */ function liveDeps(json: boolean): AgentDeps | undefined { try { return { ctx: resolveAgentContext(), request: httpRequest, json, io: { out: (line) => console.log(line), err: (line) => console.error(line) }, }; } catch (err) { if (err instanceof AgentGuardError) { console.error(palette.err(`${GLYPH.fail} ${err.message}`)); return undefined; } throw err; } } /** Run a verb with the live transport and turn its exit code into the process exit. */ async function run(json: boolean, verb: (deps: AgentDeps) => Promise): Promise { const deps = liveDeps(json); if (!deps) { process.exitCode = EXIT.refused; return; } try { process.exitCode = await verb(deps); } catch (err) { console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`)); process.exitCode = EXIT.error; } } /** Register `codeman agent …` on the program. */ export function registerAgentCommands(program: Command): Command { const agent = program .command('agent') .description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm'); agent .command('ls') .alias('list') .description('List sessions; * marks this one') .option('--json', 'Machine-readable output') .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' ) .option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude') .option('-n, --name ', 'Session name shown in the UI') .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 }) => run(Boolean(options.json), (deps) => agentSpawn(deps, { caseName, mode: options.mode, name: options.name, ready: options.ready, timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS), }) ) ); agent .command('send ') .description( 'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send -- "- fix the bug")' ) .option('-w, --wait', 'Block until end of turn (the default signal set; see --until)') .option('-u, --until ', 'Signals to wait for, comma list such as stop,exit (implies --wait)') .option('-t, --timeout ', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS)) .option('--no-enter', 'Type the text without submitting it') .option('--client-id ', 'Exactly-once tag (default: one per calling session)') .option( '--seq ', 'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed' ) .option('--json', 'Machine-readable output') .action( ( id: string, words: string[], options: { wait?: boolean; until?: string; timeout?: string; enter: boolean; clientId?: string; seq?: string; json?: boolean; } ) => run(Boolean(options.json), (deps) => { const prompt = sendPromptFromArgs(words); if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused)); return agentSend(deps, { id, text: prompt.text, enter: options.enter, wait: options.until ?? (options.wait ? true : undefined), timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS), clientId: options.clientId, seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'), }); }) ); agent .command('wait ') .description( 'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3' ) .option( '-u, --until ', 'Comma list: stop,idle,exit,working,blocked (stop needs hook signals for the session, blocked hooks or a dialog Codeman reads off the screen, as for codex; where there are none the server answers 400, passed through)' ) .option( '-m, --match ', 'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)' ) .option('--from ', 'buffer (scan existing output first, the default) or now', 'buffer') .option('--nocase', 'Case-insensitive --match') .option('--fresh', 'Require an actual transition (--until only)') .option('-t, --timeout ', 'Budget in ms', String(DEFAULT_WAIT_MS)) .option('--json', 'Machine-readable output') .action( ( id: string, options: { until?: string; match?: string; from: string; nocase?: boolean; fresh?: boolean; timeout?: string; json?: boolean; } ) => run(Boolean(options.json), (deps) => agentWait(deps, { id, until: options.until, match: options.match, from: options.from === 'now' ? 'now' : 'buffer', nocase: options.nocase, fresh: options.fresh, timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS), }) ) ); agent .command('read ') .description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal") .option('--tail ', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)') .option('--full', 'The whole conversation instead of the last assistant message') .option('--json', 'Machine-readable output') .action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) => run(Boolean(options.json), (deps) => agentRead(deps, { id, tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'), full: options.full, }) ) ); agent .command('interrupt ') .description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)') .option('--json', 'Machine-readable output') .action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentInterrupt(deps, { id })) ); agent .command('rm ') .description('Delete any session except this one (refuses your own id)') .option('--json', 'Machine-readable output') .action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id }))); return agent; }