2026-10-08 15:00:29 +02:00
/**
* @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`,
2026-10-15 09:53:17 +02:00
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the
* agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It
2026-10-08 15:00:29 +02:00
* 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.
2026-10-09 12:59:47 +02:00
* 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.
2026-10-08 15:00:29 +02:00
*
* 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' ;
2026-10-09 12:59:47 +02:00
import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js' ;
2026-10-08 15:00:29 +02:00
// ─────────────────────────────────────────────────────────────────────────────
// 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 {
2026-10-09 12:59:47 +02:00
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"\`)` ,
2026-10-08 15:00:29 +02:00
};
}
2026-10-09 12:59:47 +02:00
/**
2026-10-21 03:51:31 +02:00
* 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.
2026-10-09 12:59:47 +02:00
*/
2026-10-08 15:00:29 +02:00
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)' ;
2026-10-21 03:51:31 +02:00
// 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).
2026-10-08 15:00:29 +02:00
if ( /[\n\r\t]/ . test ( text )) {
2026-10-21 03:51:31 +02:00
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' ;
2026-10-08 15:00:29 +02:00
}
// 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 } ` : '' } ` ;
}
2026-10-09 12:59:47 +02:00
/**
* 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.
*/
2026-10-08 15:00:29 +02:00
export function stripAnsi ( text : string ) : string {
// eslint-disable-next-line no-control-regex
2026-10-09 12:59:47 +02:00
return stripAnsiSequences ( text ). replace ( /\x1b[()][AB0]/g , '' );
2026-10-08 15:00:29 +02:00
}
2026-10-24 14:15:49 +02:00
/**
* 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' ) } ` )
);
}
2026-10-08 15:00:29 +02:00
/** 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 ;
2026-10-13 13:23:56 +02:00
/** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */
signal? : AbortSignal ;
2026-10-08 15:00:29 +02:00
}
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 > {
2026-10-13 13:23:56 +02:00
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 ;
2026-10-08 15:00:29 +02:00
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 ,
2026-10-13 13:23:56 +02:00
signal : options.signal ,
2026-10-08 15:00:29 +02:00
},
( res ) => {
const chunks : Buffer [] = [];
res . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ));
2026-10-13 17:47:09 +02:00
// 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 );
2026-10-08 15:00:29 +02:00
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 ;
2026-10-09 12:59:47 +02:00
/**
* 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 ;
2026-10-08 15:00:29 +02:00
/**
* 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
2026-10-09 12:59:47 +02:00
* 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.
2026-10-08 15:00:29 +02:00
*/
2026-10-09 12:59:47 +02:00
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 ,
};
}
2026-10-08 15:00:29 +02:00
if ( id . length >= FULL_ID_LENGTH ) return { id };
const res = await deps . request ( deps . ctx , { method : 'GET' , path : '/api/v1/sessions' });
2026-10-09 12:59:47 +02:00
if ( ! res . json ? . success ) return { error : describeFailure ( res ), code : EXIT.error };
2026-10-08 15:00:29 +02:00
const matches = (( res . json . data as SessionRow [] | undefined ) ?? []). filter (( s ) => s . id . startsWith ( id ));
if ( matches . length === 1 ) return { id : matches [ 0 ]. id };
2026-10-09 12:59:47 +02:00
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 };
2026-10-08 15:00:29 +02:00
}
2026-10-15 09:53:17 +02:00
/** 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. */
2026-10-08 15:00:29 +02:00
export async function agentLs ( deps : AgentDeps ) : Promise < number > {
2026-10-15 09:53:17 +02:00
const [ res , inbox ] = await Promise . all ([
deps . request ( deps . ctx , { method : 'GET' , path : '/api/v1/sessions' }),
fetchInboxSummary ( deps ),
]);
2026-10-08 15:00:29 +02:00
if ( ! res . json ? . success ) return fail ( deps , describeFailure ( res ));
const sessions = ( res . json . data as SessionRow [] | undefined ) ?? [];
if ( deps . json ) {
emitJson (
deps ,
2026-10-15 09:53:17 +02:00
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 } : {}),
}))
2026-10-08 15:00:29 +02:00
);
return EXIT . ok ;
}
if ( sessions . length === 0 ) {
deps . io . out ( palette . muted ( '(no sessions)' ));
return EXIT . ok ;
}
2026-10-15 09:53:17 +02:00
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 }));
2026-10-08 15:00:29 +02:00
deps . io . out (
palette . muted ( `* = this session ( ${ deps . ctx . selfId . slice ( 0 , 8 ) } ). status is a UI hint, never a sync signal.` )
);
2026-10-15 09:53:17 +02:00
if ( inbox ) {
deps . io . out (
palette . muted ( 'INBOX: pending (unseen), parked = the session is waiting on `agent inbox --wait` right now.' )
);
}
2026-10-08 15:00:29 +02:00
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 < number > {
const body : Record < string , unknown > = {
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 ) {
2026-10-24 20:41:14 -04:00
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 = getErrorMessage ( error );
2026-10-08 15:00:29 +02:00
}
}
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 < 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 );
2026-10-09 12:59:47 +02:00
if ( 'error' in target ) return fail ( deps , target . error , target . code );
2026-10-08 15:00:29 +02:00
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 ));
2026-10-09 12:59:47 +02:00
const data = res . json . data as
| { delivered? : boolean ; duplicate? : boolean ; buffered? : boolean ; dropped? : boolean ; wait? : WaitResult }
| undefined ;
2026-10-08 15:00:29 +02:00
if ( deps . json ) emitJson ( deps , data ?? {});
2026-10-09 12:59:47 +02:00
// 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 ;
}
2026-10-08 15:00:29 +02:00
// `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` ));
2026-10-09 12:59:47 +02:00
} 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`
)
);
2026-10-08 15:00:29 +02:00
} 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 );
2026-10-09 12:59:47 +02:00
if ( 'error' in target ) return fail ( deps , target . error , target . code );
2026-10-08 15:00:29 +02:00
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 < number > {
const target = await resolveSessionId ( deps , options . id );
2026-10-09 12:59:47 +02:00
if ( 'error' in target ) return fail ( deps , target . error , target . code );
2026-10-08 15:00:29 +02:00
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 );
2026-10-09 12:59:47 +02:00
if ( 'error' in target ) return fail ( deps , target . error , target . code );
2026-10-08 15:00:29 +02:00
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 );
2026-10-09 12:59:47 +02:00
if ( 'error' in target ) return fail ( deps , target . error , target . code );
2026-10-08 15:00:29 +02:00
// 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 ;
}
2026-10-15 09:53:17 +02:00
// ─────────────────────────────────────────────────────────────────────────────
// 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 ;
}
2026-10-15 12:17:45 +02:00
/** 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 ;
}
}
2026-10-15 09:53:17 +02:00
/** 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
2026-10-15 12:17:45 +02:00
* 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.
2026-10-15 09:53:17 +02:00
*/
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 ?? [];
2026-10-15 12:17:45 +02:00
// `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 ;
2026-10-15 09:53:17 +02:00
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 );
2026-10-24 14:15:49 +02:00
deps . io . out ( terminalSafeText ( m . text ));
2026-10-15 09:53:17 +02:00
}
2026-10-15 12:17:45 +02:00
if ( gone ) {
2026-10-15 09:53:17 +02:00
deps . io . err ( palette . err ( ` ${ GLYPH . fail } the session went away during the wait` ));
2026-10-15 12:17:45 +02:00
} else if ( data ? . wait ? . ended ) {
deps . io . err ( palette . muted ( '(the server ended the wait early, e.g. a restart; exit 2, wait again)' ));
2026-10-15 09:53:17 +02:00
} 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 ;
}
2026-10-08 15:00:29 +02:00
// ─────────────────────────────────────────────────────────────────────────────
// 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 } ${ getErrorMessage ( err ) } ` ));
process . exitCode = EXIT . error ;
}
}
/** Register `codeman agent …` on the program. */
export function registerAgentCommands ( program : Command ) : Command {
const agent = program
. command ( 'agent' )
2026-10-15 09:53:17 +02:00
. description (
'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack'
);
2026-10-08 15:00:29 +02:00
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 session in a case (created if missing) and wait for its composer where the mode draws one'
)
. 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 ( '--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 , 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 <id> <text...>' )
2026-10-09 12:59:47 +02:00
. 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")'
)
2026-10-08 15:00:29 +02:00
. 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>' ,
2026-10-14 05:18:37 +02:00
'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)'
2026-10-08 15:00:29 +02:00
)
2026-10-09 12:59:47 +02:00
. 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)'
)
2026-10-08 15:00:29 +02:00
. 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 ( '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 }))
);
2026-10-15 09:53:17 +02:00
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' )
2026-10-15 12:17:45 +02:00
// 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 ()
2026-10-15 09:53:17 +02:00
. 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 }))
);
2026-10-08 15:00:29 +02:00
agent
. command ( 'rm <id>' )
2026-10-09 12:59:47 +02:00
. description ( 'Delete any session except this one (refuses your own id)' )
2026-10-08 15:00:29 +02:00
. option ( '--json' , 'Machine-readable output' )
. action (( id : string , options : { json? : boolean }) => run ( Boolean ( options . json ), ( deps ) => agentRm ( deps , { id })));
return agent ;
}