mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
The merged PRs (mostly #714's git fetch) added about 120 em-dashes in comments, docs, test names and three settings strings. Each one is now a comma, colon, semicolon or parentheses. The English settings strings in index.html and their i18n.js keys change together, and the zh-CN value for the fetch interval uses full-width parentheses instead of a double dash. The one left is the regex in constants.js that matches a dash typed as a word, which is behaviour, not punctuation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1520 lines
66 KiB
TypeScript
1520 lines
66 KiB
TypeScript
/**
|
||
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
|
||
* inside a Codeman session, in every CLI mode.
|
||
*
|
||
* A thin HTTP client over the server's endpoints (`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`, the agent inbox: `inbox`, `inbox/ack`,
|
||
* `agent-inbox/summary`, and the experimental, opt-in
|
||
* `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403
|
||
* until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no
|
||
* transport of its own: 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` or
|
||
* `test/cli-agent-project-spawn.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.
|
||
* 5. `spawn` without a case starts the worker in an existing directory (cwd, or
|
||
* `--working-dir`) via `POST sessions` + `interactive|shell`, without the
|
||
* `X-Codeman-Agent-Origin` header, so no agent-case marker is written. It refuses
|
||
* (exit 4, before any request) an empty `--working-dir` and a bare spawn from a
|
||
* HOME cwd: it always sends an explicit `workingDir`, so the server's skip of
|
||
* workspace hooks for a request that names none cannot protect `~/.claude`.
|
||
*
|
||
* 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 { open, 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';
|
||
import { isUtf8 } from 'node:buffer';
|
||
import { STAGE_MAX_BYTES } from './agent-staging.js';
|
||
import { INSPECT_MAX_BYTES } from './agent-inspection.js';
|
||
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<string, string> = 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 <id> "…"\`; a prompt that starts with "-" goes after --: \`send <id> -- "- fix the bug"\`)`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Why `send` refuses this text, or `undefined` when it is printable. A line break is
|
||
* refused for the reason below; 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 <id> ""` is never a prompt)';
|
||
// The server pastes a multi-line prompt as one block, but the check that re-presses
|
||
// a stranded Enter cannot reliably recognise that block in the composer, so a wait
|
||
// could burn its timeout on a prompt that never went out. 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 (a multi-line paste cannot be confirmed as submitted) — 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<string, unknown> {
|
||
const body: Record<string, unknown> = {
|
||
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<string, unknown> {
|
||
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, '');
|
||
}
|
||
|
||
/**
|
||
* Inbox message text for a terminal. Another session wrote it, so an escape sequence
|
||
* in it would otherwise reach the reader's terminal (retitle it, or move the cursor
|
||
* over the `from` line above it). ANSI sequences go as in `read --tail`, and every
|
||
* control character left (C0 other than newline and tab, DEL, C1) is shown as `\xHH`,
|
||
* so a bare `\r` or backspace cannot overwrite the header either. A CRLF line end
|
||
* stays a line end. `--json` prints the text as stored.
|
||
*/
|
||
export function terminalSafeText(text: string): string {
|
||
return (
|
||
stripAnsi(text.replace(/\r\n/g, '\n'))
|
||
// eslint-disable-next-line no-control-regex
|
||
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||
);
|
||
}
|
||
|
||
/** 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<T = unknown> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
errorCode?: string;
|
||
}
|
||
|
||
export interface ApiResponse<T = unknown> {
|
||
status: number;
|
||
/** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
|
||
json?: ApiEnvelope<T>;
|
||
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<string, string | number | boolean | undefined>;
|
||
body?: Record<string, unknown>;
|
||
headers?: Record<string, string>;
|
||
/** 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<ApiResponse>;
|
||
|
||
/** Every request carries these; they are ignored on endpoints that do not read them. */
|
||
export function baseHeaders(ctx: AgentContext): Record<string, string> {
|
||
const headers: Record<string, string> = { 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<string, string | number> = { ...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 thrown request error. Node 22 rejects a refused connection to a host
|
||
* that resolves to both ::1 and 127.0.0.1 with an AggregateError whose `message` is empty, so
|
||
* fall back to its `code` (ECONNREFUSED) before a generic line rather than printing nothing.
|
||
*/
|
||
export function describeRequestError(error: unknown): string {
|
||
const message = getErrorMessage(error);
|
||
if (message) return message;
|
||
const code = (error as { code?: unknown } | null | undefined)?.code;
|
||
return code ? String(code) : 'request failed without a message';
|
||
}
|
||
|
||
/** 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): the envelope's `data`, never a reshaped copy,
|
||
* 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));
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
/** One session's row in `GET /api/agent-inbox/summary`. */
|
||
export interface InboxSummaryRow {
|
||
pending: number;
|
||
unseen: number;
|
||
oldestUnseenAt: number | null;
|
||
parked: boolean;
|
||
}
|
||
|
||
/**
|
||
* The inbox summary, or undefined when it cannot be had (a server without the route,
|
||
* a transient error). `ls` must still list sessions then, so this never fails the verb.
|
||
*/
|
||
async function fetchInboxSummary(deps: AgentDeps): Promise<Record<string, InboxSummaryRow> | undefined> {
|
||
try {
|
||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/agent-inbox/summary' });
|
||
if (!res.json?.success) return undefined;
|
||
return (res.json.data as { sessions?: Record<string, InboxSummaryRow> } | undefined)?.sessions ?? {};
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/** The INBOX cell of `ls`: `3 (1 new)`, `parked`, both, or empty. */
|
||
export function inboxCell(row: InboxSummaryRow | undefined): string {
|
||
if (!row) return '';
|
||
const parts: string[] = [];
|
||
if (row.pending > 0) parts.push(row.unseen > 0 ? `${row.pending} (${row.unseen} new)` : String(row.pending));
|
||
if (row.parked) parts.push('parked');
|
||
return parts.join(', ');
|
||
}
|
||
|
||
/** `agent ls` — every session the caller can see, self marked, with its inbox counts. */
|
||
export async function agentLs(deps: AgentDeps): Promise<number> {
|
||
const [res, inbox] = await Promise.all([
|
||
deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }),
|
||
fetchInboxSummary(deps),
|
||
]);
|
||
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),
|
||
// null = empty inbox and not parked. The key is absent when the server did
|
||
// not answer the summary, so "no data" never reads as "no mail".
|
||
...(inbox ? { inbox: inbox[s.id] ?? null } : {}),
|
||
}))
|
||
);
|
||
return EXIT.ok;
|
||
}
|
||
if (sessions.length === 0) {
|
||
deps.io.out(palette.muted('(no sessions)'));
|
||
return EXIT.ok;
|
||
}
|
||
const rows = sessions.map((s) => {
|
||
const row = [isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ', s.id.slice(0, 8), s.mode ?? '?', s.status ?? '?'];
|
||
if (inbox) row.push(inboxCell(inbox[s.id]));
|
||
row.push(s.name || s.workingDir || '');
|
||
return row;
|
||
});
|
||
const header = inbox ? [' ', 'ID', 'MODE', 'STATUS', 'INBOX', 'NAME'] : [' ', 'ID', 'MODE', 'STATUS', 'NAME'];
|
||
deps.io.out(table([header, ...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.`)
|
||
);
|
||
if (inbox) {
|
||
deps.io.out(
|
||
palette.muted('INBOX: pending (unseen), parked = the session is waiting on `agent inbox --wait` right now.')
|
||
);
|
||
}
|
||
return EXIT.ok;
|
||
}
|
||
|
||
export interface SpawnOptions {
|
||
caseName?: string;
|
||
workingDir?: 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;
|
||
}
|
||
|
||
/** realpath, or the plain resolved path when it cannot be resolved; never throws. */
|
||
async function canonicalPath(path: string): Promise<string> {
|
||
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<boolean> {
|
||
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<number> {
|
||
const cli = getCli(options.mode);
|
||
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());
|
||
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);
|
||
}
|
||
// 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<string, unknown> = {
|
||
...(workingDir ? { workingDir } : { caseName: options.caseName }),
|
||
mode: options.mode,
|
||
parentSessionId: deps.ctx.selfId,
|
||
};
|
||
if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name;
|
||
const res = await deps.request(deps.ctx, {
|
||
method: 'POST',
|
||
path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start',
|
||
body,
|
||
headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER },
|
||
});
|
||
const created = res.json?.data as
|
||
| { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string }
|
||
| undefined;
|
||
// `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;
|
||
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;
|
||
let dead = false;
|
||
const mark = composerReadyMark(options.mode);
|
||
if (options.ready && mark) {
|
||
try {
|
||
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);
|
||
}
|
||
} catch (error) {
|
||
readinessError = describeRequestError(error);
|
||
}
|
||
}
|
||
|
||
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}, ${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) {
|
||
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<number> {
|
||
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<number> {
|
||
if (options.until && options.match)
|
||
return fail(deps, 'use either --until <signals> or --match <marker>, 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);
|
||
}
|
||
|
||
/** Bounded incremental evidence, with an opaque server-owned cursor. */
|
||
export async function agentInspect(
|
||
deps: AgentDeps,
|
||
options: { id: string; maxBytes: number; since?: string }
|
||
): Promise<number> {
|
||
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES)
|
||
return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused);
|
||
const target = await resolveSessionId(deps, options.id);
|
||
if ('error' in target) return fail(deps, target.error, target.code);
|
||
const response = await deps.request(deps.ctx, {
|
||
method: 'GET',
|
||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`,
|
||
query: { maxBytes: options.maxBytes, since: options.since },
|
||
});
|
||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||
if (deps.json) emitJson(deps, response.json.data);
|
||
else {
|
||
const data = response.json.data as {
|
||
source?: string;
|
||
evidence?: string;
|
||
truncated?: boolean;
|
||
cursor?: string;
|
||
blockedReason?: string;
|
||
};
|
||
deps.io.out(
|
||
`[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}`
|
||
);
|
||
deps.io.out(data.evidence ?? '');
|
||
if (data.cursor) deps.io.out(`cursor: ${data.cursor}`);
|
||
}
|
||
return EXIT.ok;
|
||
}
|
||
|
||
/** Stage exactly the printable UTF8 file contents; never sends Enter. */
|
||
export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise<number> {
|
||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||
let message: string;
|
||
try {
|
||
const info = await stat(options.messageFile);
|
||
if (!info.isFile() || info.size > STAGE_MAX_BYTES)
|
||
return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused);
|
||
const handle = await open(options.messageFile, 'r');
|
||
try {
|
||
const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1);
|
||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||
const bytes = buffer.subarray(0, bytesRead);
|
||
if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes))
|
||
return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused);
|
||
message = bytes.toString('utf8');
|
||
} finally {
|
||
await handle.close();
|
||
}
|
||
} catch (error) {
|
||
return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused);
|
||
}
|
||
const refusal = inputRefusal(message);
|
||
if (refusal || message !== message.trimEnd())
|
||
return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused);
|
||
const target = await resolveSessionId(deps, options.id);
|
||
if ('error' in target) return fail(deps, target.error, target.code);
|
||
const response = await deps.request(deps.ctx, {
|
||
method: 'POST',
|
||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`,
|
||
body: { message },
|
||
});
|
||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||
if (deps.json) emitJson(deps, response.json.data);
|
||
else {
|
||
const data = response.json.data as { stageId: string; sha256: string; expiresAt: number };
|
||
deps.io.out(
|
||
`staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}`
|
||
);
|
||
}
|
||
return EXIT.ok;
|
||
}
|
||
|
||
/** Submit one verified staged composer; failure never automatically replays Enter. */
|
||
export async function agentSubmit(
|
||
deps: AgentDeps,
|
||
options: { id: string; stageId: string; confirmSha: string }
|
||
): Promise<number> {
|
||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||
if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId))
|
||
return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused);
|
||
const target = await resolveSessionId(deps, options.id);
|
||
if ('error' in target) return fail(deps, target.error, target.code);
|
||
const response = await deps.request(deps.ctx, {
|
||
method: 'POST',
|
||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`,
|
||
body: { stageId: options.stageId, confirmSha: options.confirmSha },
|
||
});
|
||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||
if (deps.json) emitJson(deps, response.json.data);
|
||
else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).');
|
||
return EXIT.ok;
|
||
}
|
||
|
||
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<number> {
|
||
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<number> {
|
||
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<number> {
|
||
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;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Agent inbox
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** A message as `GET …/inbox` hands it out. */
|
||
export interface InboxMessageRow {
|
||
id: string;
|
||
from: string | null;
|
||
text: string;
|
||
createdAt: number;
|
||
seen: boolean;
|
||
}
|
||
|
||
/**
|
||
* The message `post` was given, which must be ONE argument, as with `send`: several
|
||
* would be an unquoted `$(cat notes.md)` split by the shell, and joining the pieces
|
||
* with spaces loses its newlines. Unlike `send`, newlines inside the one argument are
|
||
* fine: the inbox stores text as data and never types it.
|
||
*/
|
||
export function postTextFromArgs(words: readonly string[]): { text: string } | { error: string } {
|
||
if (words.length === 1) return { text: words[0] };
|
||
return { error: `refusing: the message must be ONE argument, got ${words.length} — quote it` };
|
||
}
|
||
|
||
/** `agent post` — store a message in another session's inbox. Never types into its pane. */
|
||
export async function agentPost(deps: AgentDeps, options: { id: string; text: string }): Promise<number> {
|
||
if (options.text.length === 0) return fail(deps, 'refusing: empty message', EXIT.refused);
|
||
const target = await resolveSessionId(deps, options.id);
|
||
if ('error' in target) return fail(deps, target.error, target.code);
|
||
const res = await deps.request(deps.ctx, {
|
||
method: 'POST',
|
||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
|
||
body: { text: options.text },
|
||
});
|
||
// 409 = the receiver's inbox is full; nothing was stored. Passed through as an error.
|
||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||
const data = res.json.data as { message?: InboxMessageRow; pending?: number; unseen?: number } | undefined;
|
||
if (deps.json) emitJson(deps, data ?? {});
|
||
else
|
||
deps.io.out(
|
||
palette.ok(
|
||
`${GLYPH.ok} posted to ${target.id.slice(0, 8)} (${data?.pending ?? '?'} pending, ${data?.unseen ?? '?'} unseen)`
|
||
)
|
||
);
|
||
return EXIT.ok;
|
||
}
|
||
|
||
export interface InboxReadOptions {
|
||
/** Another session's inbox (a peek); omitted = this session's own. */
|
||
id?: string;
|
||
/** Long-poll budget in ms; the read parks while nothing is unseen. */
|
||
waitMs?: number;
|
||
}
|
||
|
||
/** True only when the session list was read and does not contain `id`. */
|
||
async function sessionIsGone(deps: AgentDeps, id: string): Promise<boolean> {
|
||
try {
|
||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||
if (!res.json?.success) return false;
|
||
return !((res.json.data as SessionRow[] | undefined) ?? []).some((s) => s.id === id);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Relative age for humans: `12s`, `4m`, `3h`. */
|
||
function age(createdAt: number, now: number): string {
|
||
const s = Math.max(0, Math.round((now - createdAt) / 1000));
|
||
if (s < 60) return `${s}s`;
|
||
if (s < 3600) return `${Math.round(s / 60)}m`;
|
||
return `${Math.round(s / 3600)}h`;
|
||
}
|
||
|
||
/**
|
||
* `agent inbox` — read this session's inbox (or peek at another's). Removes nothing:
|
||
* a message stays until `agent ack <id>`. Only reading your OWN inbox marks messages
|
||
* seen. Exit 0 with mail, 2 when a `--wait` ran out with nothing unseen or the server
|
||
* cut it short (restart, detach), 3 when the session is gone from the list.
|
||
*/
|
||
export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions): Promise<number> {
|
||
const target = await resolveSessionId(deps, options.id ?? deps.ctx.selfId);
|
||
if ('error' in target) return fail(deps, target.error, target.code);
|
||
const res = await deps.request(deps.ctx, {
|
||
method: 'GET',
|
||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
|
||
query: { wait: options.waitMs },
|
||
timeoutMs: options.waitMs !== undefined ? options.waitMs + 10_000 : undefined,
|
||
});
|
||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||
const data = res.json.data as
|
||
| {
|
||
receiver?: boolean;
|
||
messages?: InboxMessageRow[];
|
||
pending?: number;
|
||
unseen?: number;
|
||
wait?: { timedOut?: boolean; ended?: boolean };
|
||
}
|
||
| undefined;
|
||
const messages = data?.messages ?? [];
|
||
// `ended` is also what a server restart or a detach answers. Exit 3 tells a receiver
|
||
// loop to stop, so it needs the session list to confirm the session is gone; while
|
||
// it is still listed (or the list cannot be had, as during a restart) the wait was
|
||
// only cut short, which is exit 2: loop again.
|
||
const gone = data?.wait?.ended ? await sessionIsGone(deps, target.id) : false;
|
||
const code = gone ? EXIT.dead : data?.wait?.timedOut || data?.wait?.ended ? EXIT.timeout : EXIT.ok;
|
||
if (deps.json) {
|
||
emitJson(deps, data ?? {});
|
||
return code;
|
||
}
|
||
const now = (deps.now ?? Date.now)();
|
||
for (const m of messages) {
|
||
const sender = m.from ? m.from.slice(0, 8) : 'user';
|
||
const tag = m.seen ? palette.muted('seen') : palette.emph('new');
|
||
deps.io.out(palette.muted(`── ${m.id} from ${sender}, ${age(m.createdAt, now)} ago, `) + tag);
|
||
deps.io.out(terminalSafeText(m.text));
|
||
}
|
||
if (gone) {
|
||
deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`));
|
||
} else if (data?.wait?.ended) {
|
||
deps.io.err(palette.muted('(the server ended the wait early, e.g. a restart; exit 2, wait again)'));
|
||
} else if (messages.length === 0) {
|
||
deps.io.err(palette.muted(data?.wait?.timedOut ? '(no mail; wait timed out, exit 2)' : '(no mail)'));
|
||
} else if (data?.receiver) {
|
||
deps.io.err(
|
||
palette.muted(
|
||
`${messages.length} pending. Nothing is removed by reading: when done, \`codeman agent ack <id>…\` (full ids above).`
|
||
)
|
||
);
|
||
} else {
|
||
deps.io.err(palette.muted(`${messages.length} pending (peek: nothing was marked seen).`));
|
||
}
|
||
return code;
|
||
}
|
||
|
||
/** `agent ack` — remove messages from THIS session's inbox by id. Idempotent. */
|
||
export async function agentAck(deps: AgentDeps, options: { ids: string[] }): Promise<number> {
|
||
const ids = options.ids.map((id) => id.trim()).filter(Boolean);
|
||
if (ids.length === 0) return fail(deps, 'refusing: no message ids', EXIT.refused);
|
||
const self = await resolveSessionId(deps, deps.ctx.selfId);
|
||
if ('error' in self) return fail(deps, self.error, self.code);
|
||
const res = await deps.request(deps.ctx, {
|
||
method: 'POST',
|
||
path: `/api/v1/sessions/${encodeURIComponent(self.id)}/inbox/ack`,
|
||
body: { ids },
|
||
});
|
||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||
const data = res.json.data as { removed?: number; pending?: number } | undefined;
|
||
if (deps.json) emitJson(deps, data ?? {});
|
||
else deps.io.out(palette.ok(`${GLYPH.ok} acked ${data?.removed ?? 0} (${data?.pending ?? '?'} still pending)`));
|
||
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<number>): Promise<void> {
|
||
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} ${describeRequestError(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, the inbox: post, inbox, ack, and the experimental, opt-in inspect, stage, submit'
|
||
);
|
||
|
||
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 [case]')
|
||
.description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported')
|
||
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
|
||
.option('-n, --name <name>', 'Session name shown in the UI')
|
||
.option(
|
||
'--working-dir <directory>',
|
||
'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 <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
|
||
.option('--json', 'Machine-readable output')
|
||
.action(
|
||
(
|
||
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,
|
||
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
|
||
})
|
||
)
|
||
);
|
||
|
||
agent
|
||
.command('send <id> <text...>')
|
||
.description(
|
||
'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send <id> -- "- fix the bug")'
|
||
)
|
||
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
|
||
.option('-u, --until <signals>', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
|
||
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
|
||
.option('--no-enter', 'Type the text without submitting it')
|
||
.option('--client-id <id>', 'Exactly-once tag (default: one per calling session)')
|
||
.option(
|
||
'--seq <n>',
|
||
'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 <id>')
|
||
.description(
|
||
'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
|
||
)
|
||
.option(
|
||
'-u, --until <signals>',
|
||
'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 <marker>',
|
||
'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 <where>', '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 <ms>', '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('inspect <id>')
|
||
.description(
|
||
'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors'
|
||
)
|
||
.option('--max-bytes <n>', 'UTF8 evidence budget (1–65536)', '8192')
|
||
.option('--since <cursor>', 'Resume a cursor from the same owner, pane and transcript')
|
||
.option('--json', 'Machine-readable output')
|
||
.action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) =>
|
||
run(Boolean(options.json), (deps) =>
|
||
agentInspect(deps, {
|
||
id,
|
||
maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'),
|
||
since: options.since,
|
||
})
|
||
)
|
||
);
|
||
|
||
agent
|
||
.command('stage <id>')
|
||
.description(
|
||
'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)'
|
||
)
|
||
.requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline')
|
||
.option('--json', 'Machine-readable output')
|
||
.action((id: string, options: { messageFile: string; json?: boolean }) =>
|
||
run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile }))
|
||
);
|
||
|
||
agent
|
||
.command('submit <id>')
|
||
.description(
|
||
'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match'
|
||
)
|
||
.requiredOption('--stage-id <receipt>', 'One-shot stage receipt')
|
||
.requiredOption('--confirm-sha <hash>', 'Exact SHA-256 returned by stage')
|
||
.option('--json', 'Machine-readable output')
|
||
.action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) =>
|
||
run(Boolean(options.json), (deps) =>
|
||
agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha })
|
||
)
|
||
);
|
||
|
||
agent
|
||
.command('read <id>')
|
||
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
|
||
.option('--tail <bytes>', '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 <id>')
|
||
.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('post <id> <text...>')
|
||
.description(
|
||
"Leave a message in another session's inbox (ONE quoted argument; multi-line is fine). Nothing is typed into its pane: it reads with `agent inbox`"
|
||
)
|
||
.option('--json', 'Machine-readable output')
|
||
// A message is often a file (`"$(cat task.md)"`) and markdown starts with `- ` or
|
||
// `---` often enough; without this commander takes it for an unknown option.
|
||
// `--json` and `-h` are still options: text that is exactly one goes after `--`.
|
||
.allowUnknownOption()
|
||
.action((id: string, words: string[], options: { json?: boolean }) =>
|
||
run(Boolean(options.json), (deps) => {
|
||
const message = postTextFromArgs(words);
|
||
if ('error' in message) return Promise.resolve(fail(deps, message.error, EXIT.refused));
|
||
return agentPost(deps, { id, text: message.text });
|
||
})
|
||
);
|
||
|
||
agent
|
||
.command('inbox [id]')
|
||
.description(
|
||
"Read this session's inbox (or peek at another's with [id]). Reading removes nothing; `agent ack` does. --wait blocks while nothing is new: exit 2 on timeout"
|
||
)
|
||
.option('-w, --wait', 'Block until a new (unseen) message arrives')
|
||
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
|
||
.option('--json', 'Machine-readable output')
|
||
.action((id: string | undefined, options: { wait?: boolean; timeout?: string; json?: boolean }) =>
|
||
run(Boolean(options.json), (deps) =>
|
||
agentInboxRead(deps, {
|
||
id,
|
||
waitMs: options.wait ? parsePositiveInt(options.timeout, DEFAULT_WAIT_MS) : undefined,
|
||
})
|
||
)
|
||
);
|
||
|
||
agent
|
||
.command('ack <ids...>')
|
||
.description("Remove messages from this session's inbox by id, once handled (unknown ids are not an error)")
|
||
.option('--json', 'Machine-readable output')
|
||
.action((ids: string[], options: { json?: boolean }) =>
|
||
run(Boolean(options.json), (deps) => agentAck(deps, { ids }))
|
||
);
|
||
|
||
agent
|
||
.command('rm <id>')
|
||
.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;
|
||
}
|