mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
feat(cli): add codeman agent — session-to-session verbs for every CLI mode
The agent skill teaches the session verbs to claude only (Codeman seeds its preamble for local claude sessions). An opencode, codex, pi or gemini agent has the same environment — CODEMAN_MUX, CODEMAN_SESSION_ID and CODEMAN_API_URL are exported into every pane — and nothing that teaches it the verbs, so `codeman agent ls|spawn|send|wait|read|interrupt|rm` packages them as commands. It is a client of the server, like `codeman tui`, and stays out of `codeman session` (which drives the in-process SessionManager). No new route and no second transport: everything goes through CODEMAN_API_URL, so auth, ownership and the per-session waiter cap apply unchanged. Credentials and the Basic header come from src/codeman-credentials.ts, in the same order attach and the TUI use. Invariants, each in test/cli-agent.test.ts: - Refuses outside a Codeman session (CODEMAN_MUX=1 + CODEMAN_API_URL); never guesses a URL. - `send` takes the prompt as ONE argument (an unquoted multi-line `$(…)` would otherwise be split by the shell and re-joined into one line), transmits printable text plus Enter only, and refuses multi-line input loudly instead of letting sendInput weld the lines. No resend loop of its own: the server's SubmitVerifier owns the swallowed-Enter case. ESC exists only as `interrupt`, which never appends Enter. - `rm` fails closed: empty id, an unprovable self id, or a prefix match in either direction refuses. - Nothing mode-shaped in the CLI: the readiness mark `spawn` waits for comes from the registry (new `capabilities.composerReadyMark`: claude's composer hint `shift+tab`, deepseek's `❯`), `read` relies on the route's own answer dispatch, and a `stop`/`blocked` the session cannot fire is the server's 400, passed through. A `/wait` timeout is a 200 with `timedOut`: exit 2 with a neutral line, not an error. A worker that dies during spawn's readiness wait is exit 3, like every other wait. - `X-Codeman-Agent-Origin` rides only spawn's quick-start, the one request that may create a case directory; every other verb leaves it off. Server-side, test/routes/agent-case-marker-routes.test.ts pins that a POST /api/sessions on an existing workingDir is never labelled, header or not, and that quick-start labels only a directory it creates. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6d862d5323
commit
0481db569d
13 files changed
+1666
No files matched your search
@@ -200,6 +200,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
||||
|
||||
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||
|
||||
@@ -944,6 +944,23 @@ codeman tui --list # numbered session list (plain tex
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
### `codeman agent` — session-to-session verbs in every CLI mode
|
||||
|
||||
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent wait "$SID" --match DONE_4711 # marker wait for hook-less modes (opencode, pi, …)
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
codeman agent rm "$SID" # refuses your own id
|
||||
```
|
||||
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
|
||||
@@ -466,6 +466,10 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
|
||||
## Session lineage (`parentSessionId`)
|
||||
|
||||
A create request may name the session that spawned it, which the web UI draws as a
|
||||
|
||||
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
|
||||
Workers in **every other mode** never receive this preamble, but they have the same
|
||||
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
|
||||
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
|
||||
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
|
||||
URL) enforced in code.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
|
||||
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
|
||||
Workers in **every other mode** never receive this preamble, but they have the same
|
||||
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
|
||||
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
|
||||
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
|
||||
URL) enforced in code.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
/**
|
||||
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
|
||||
* inside a Codeman session, in every CLI mode.
|
||||
*
|
||||
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
|
||||
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
|
||||
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
|
||||
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
|
||||
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
|
||||
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
|
||||
* preamble — has the same verbs from one line in its AGENTS.md.
|
||||
*
|
||||
* Invariants (each asserted in `test/cli-agent.test.ts`):
|
||||
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
|
||||
* never guesses a URL — a server you are not part of is not yours to drive.
|
||||
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
|
||||
* `interrupt`, which never appends `\r`. A stray control byte is a dead session
|
||||
* in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
|
||||
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
|
||||
* direction refuses. Ids appear in full and 8-char form, so equality alone
|
||||
* misses a real combination — and the miss deletes the caller.
|
||||
*
|
||||
* 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';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. The composer
|
||||
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
|
||||
* DEL) reaches the pane as a keypress. None of that is a prompt.
|
||||
*/
|
||||
/**
|
||||
* 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> "…"\`)`,
|
||||
};
|
||||
}
|
||||
|
||||
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 composer is one line: the server strips newlines, which silently joins the
|
||||
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
|
||||
if (/[\n\r\t]/.test(text)) {
|
||||
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
|
||||
}
|
||||
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
|
||||
if (control) {
|
||||
const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
|
||||
return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
|
||||
* caller asked to type without submitting. Never anything else.
|
||||
*/
|
||||
export function buildSendBody(
|
||||
text: string,
|
||||
options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
|
||||
): Record<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 ANSI CSI/charset sequences from a terminal buffer (GNU and BSD alike). */
|
||||
export function stripAnsi(text: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
|
||||
}
|
||||
|
||||
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
|
||||
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`${flag} must be a positive integer, got "${raw}"`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
|
||||
* timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
|
||||
* during `--until stop` comes back as `ended:true, signal:null` (the registry only
|
||||
* satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
|
||||
* a dead worker as `ended:true, matched:false` — both are "dead", never "done".
|
||||
*/
|
||||
export function waitExitCode(wait: WaitResult | undefined): number {
|
||||
if (!wait) return EXIT.error;
|
||||
if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
|
||||
if (wait.timedOut) return EXIT.timeout;
|
||||
if (wait.matched === false) return EXIT.timeout;
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiEnvelope<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;
|
||||
}
|
||||
|
||||
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.
|
||||
'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,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function resolveSessionId(deps: AgentDeps, id: string): Promise<{ id: string } | { error: string }> {
|
||||
if (!id) return { error: 'refusing: empty session id' };
|
||||
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) };
|
||||
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\`)` };
|
||||
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}` };
|
||||
}
|
||||
|
||||
/** `agent ls` — every session the caller can see, self marked. */
|
||||
export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
|
||||
if (deps.json) {
|
||||
emitJson(
|
||||
deps,
|
||||
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
deps.io.out(palette.muted('(no sessions)'));
|
||||
return EXIT.ok;
|
||||
}
|
||||
const rows = sessions.map((s) => [
|
||||
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
|
||||
s.id.slice(0, 8),
|
||||
s.mode ?? '?',
|
||||
s.status ?? '?',
|
||||
s.name || s.workingDir || '',
|
||||
]);
|
||||
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
|
||||
deps.io.out(
|
||||
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
caseName: string;
|
||||
mode: string;
|
||||
name?: string;
|
||||
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
|
||||
ready: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-scratch label for a case directory a spawn CREATES (the server applies it only
|
||||
* when quick-start makes the directory). The Add Case UI offers a recursive delete for
|
||||
* such directories, so this header must never ride any other request: mislabelling a
|
||||
* real repo there is the one failure in this area that costs actual work.
|
||||
*/
|
||||
export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
|
||||
|
||||
/**
|
||||
* What the mode's TUI draws once its composer can take a prompt, from the CLI registry
|
||||
* (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
|
||||
*/
|
||||
export function composerReadyMark(mode: string): string | undefined {
|
||||
return getCli(mode)?.capabilities.composerReadyMark;
|
||||
}
|
||||
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
|
||||
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<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) {
|
||||
const wait = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
|
||||
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
|
||||
timeoutMs: options.timeoutMs + 10_000,
|
||||
});
|
||||
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
|
||||
// composer never showed up": report the real reason instead of the trust-dialog hint.
|
||||
if (!wait.json?.success) readinessError = describeFailure(wait);
|
||||
else {
|
||||
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
|
||||
// A worker that died while we waited is exit 3 like every other wait, not a
|
||||
// "composer not seen" timeout that sends the caller looking for a dialog.
|
||||
dead = waitExitCode(result) === EXIT.dead;
|
||||
ready = !dead && Boolean(result?.matched);
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.json) {
|
||||
emitJson(deps, { ...data, ready, readinessError });
|
||||
} else {
|
||||
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
|
||||
const say = (line: string) => deps.io.err(line);
|
||||
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
|
||||
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
|
||||
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
|
||||
if (ready === false && !dead) {
|
||||
say(
|
||||
palette.warn(
|
||||
`${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
|
||||
if (ready === undefined && !readinessError && options.ready) {
|
||||
say(
|
||||
palette.muted(
|
||||
` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
|
||||
)
|
||||
);
|
||||
}
|
||||
deps.io.out(sid);
|
||||
}
|
||||
if (readinessError) return EXIT.error;
|
||||
if (dead) return EXIT.dead;
|
||||
return ready === false ? EXIT.timeout : EXIT.ok;
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
id: string;
|
||||
text: string;
|
||||
enter: boolean;
|
||||
/** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
|
||||
wait?: string | true;
|
||||
timeoutMs?: number;
|
||||
clientId?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
|
||||
export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<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);
|
||||
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; wait?: WaitResult } | undefined;
|
||||
if (deps.json) emitJson(deps, data ?? {});
|
||||
// `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?.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);
|
||||
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);
|
||||
if ('error' in target) return fail(deps, target.error);
|
||||
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);
|
||||
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);
|
||||
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
|
||||
// to me only if the list is lying, but a delete is the one call worth the paranoia.
|
||||
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
|
||||
if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'DELETE',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
if (deps.json) emitJson(deps, res.json.data ?? {});
|
||||
else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Commander wiring
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WAIT_MS = 60_000;
|
||||
|
||||
/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
|
||||
function liveDeps(json: boolean): AgentDeps | undefined {
|
||||
try {
|
||||
return {
|
||||
ctx: resolveAgentContext(),
|
||||
request: httpRequest,
|
||||
json,
|
||||
io: { out: (line) => console.log(line), err: (line) => console.error(line) },
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AgentGuardError) {
|
||||
console.error(palette.err(`${GLYPH.fail} ${err.message}`));
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a verb with the live transport and turn its exit code into the process exit. */
|
||||
async function run(json: boolean, verb: (deps: AgentDeps) => Promise<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')
|
||||
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
|
||||
|
||||
agent
|
||||
.command('ls')
|
||||
.alias('list')
|
||||
.description('List sessions; * marks this one')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
|
||||
|
||||
agent
|
||||
.command('spawn <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...>')
|
||||
.description('Type a prompt into another session and press Enter (ONE quoted argument, printable text only)')
|
||||
.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/blocked need hook signals for the session; 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)')
|
||||
.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 }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('rm <id>')
|
||||
.description('Delete a session you created (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;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { registerAgentCommands } from './cli-agent.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
@@ -229,6 +230,10 @@ skillCmd
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Agent Commands (session-to-session, any CLI mode) ============
|
||||
|
||||
registerAgentCommands(program);
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
|
||||
@@ -320,6 +320,8 @@ const capabilitiesSchema = z
|
||||
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
||||
// transcript layout nobody has measured is never touched.
|
||||
transcriptGutter: z.number().int().min(1).max(8).optional(),
|
||||
// Literal text matched by a `wait-output` long-poll, never compiled as a regex.
|
||||
composerReadyMark: z.string().min(1).max(64).optional(),
|
||||
workDetect: z
|
||||
.object({
|
||||
promptGlyph: z.string().min(1).max(8),
|
||||
|
||||
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
|
||||
// in them, so a copy can drop two and paste flush. Claude and codex are the only
|
||||
// entries that declare this, because theirs are the only gutters that have been measured.
|
||||
transcriptGutter: 2,
|
||||
// The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
|
||||
// trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
|
||||
composerReadyMark: 'shift+tab',
|
||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||
@@ -1342,6 +1345,8 @@ const DEEPSEEK: CliEntry = {
|
||||
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
|
||||
// the session can disarm the bridge, and docker/remote cannot reach it at all.
|
||||
hooks: 'supervised',
|
||||
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
|
||||
composerReadyMark: '❯',
|
||||
transcript: 'deepseek-zstd',
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
|
||||
@@ -416,6 +416,17 @@ export interface CliCapabilities {
|
||||
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
||||
*/
|
||||
transcriptGutter?: number;
|
||||
/**
|
||||
* Literal text the TUI draws once its composer can take a prompt — what `codeman agent
|
||||
* spawn` waits for (a `wait-output` match) before it calls a worker ready.
|
||||
*
|
||||
* Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
|
||||
* of its workspace-trust dialog, which is exactly the screen a readiness wait must not
|
||||
* mistake for a composer, so claude declares its composer's own hint text instead.
|
||||
* Absent means no readiness wait: a spawn returns as soon as the session exists, and
|
||||
* the caller synchronizes on `wait-output` markers.
|
||||
*/
|
||||
composerReadyMark?: string;
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
/**
|
||||
* @fileoverview `codeman agent …` — the three invariants from `src/cli-agent.ts`
|
||||
* plus every verb against a recording fake transport, and the real HTTP transport
|
||||
* against a local server (headers, auth, query encoding).
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentGuardError,
|
||||
EXIT,
|
||||
agentInterrupt,
|
||||
agentLs,
|
||||
agentRead,
|
||||
agentRm,
|
||||
agentSend,
|
||||
agentSpawn,
|
||||
agentWait,
|
||||
baseHeaders,
|
||||
sendPromptFromArgs,
|
||||
composerReadyMark,
|
||||
buildInterruptBody,
|
||||
buildSendBody,
|
||||
deleteRefusal,
|
||||
describeFailure,
|
||||
httpRequest,
|
||||
inputRefusal,
|
||||
isSelfSession,
|
||||
parsePositiveInt,
|
||||
resolveAgentContext,
|
||||
stripAnsi,
|
||||
waitExitCode,
|
||||
type AgentContext,
|
||||
type AgentDeps,
|
||||
type ApiResponse,
|
||||
type RequestOptions,
|
||||
} from '../src/cli-agent.js';
|
||||
import { readCodemanEnvFile } from '../src/codeman-credentials.js';
|
||||
|
||||
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
|
||||
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
|
||||
|
||||
function ctx(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||
return { apiUrl: 'http://127.0.0.1:1', selfId: SELF, ...overrides };
|
||||
}
|
||||
|
||||
/** Recording transport: answers from a queue (or a resolver) and keeps every call. */
|
||||
function fakeDeps(
|
||||
answer: ((options: RequestOptions) => ApiResponse) | ApiResponse[],
|
||||
json = false
|
||||
): AgentDeps & { calls: RequestOptions[]; out: string[]; err: string[] } {
|
||||
const calls: RequestOptions[] = [];
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const queue = Array.isArray(answer) ? [...answer] : undefined;
|
||||
return {
|
||||
ctx: ctx(),
|
||||
calls,
|
||||
out,
|
||||
err,
|
||||
json,
|
||||
now: () => 1_700_000_000_000,
|
||||
io: { out: (l) => out.push(l), err: (l) => err.push(l) },
|
||||
request: async (_c, options) => {
|
||||
calls.push(options);
|
||||
if (queue) {
|
||||
const next = queue.shift();
|
||||
if (!next) throw new Error('fake transport: no answer queued');
|
||||
return next;
|
||||
}
|
||||
return (answer as (o: RequestOptions) => ApiResponse)(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ok(data: unknown): ApiResponse {
|
||||
return { status: 200, json: { success: true, data }, text: '' };
|
||||
}
|
||||
|
||||
function apiError(status: number, errorCode: string, error: string): ApiResponse {
|
||||
return { status, json: { success: false, errorCode, error }, text: '' };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invariant 1: the guard
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveAgentContext (guard)', () => {
|
||||
const inside = { CODEMAN_MUX: '1', CODEMAN_API_URL: 'http://127.0.0.1:3459', CODEMAN_SESSION_ID: SELF };
|
||||
|
||||
it('refuses outside a Codeman session', () => {
|
||||
expect(() => resolveAgentContext({}, () => ({}))).toThrow(AgentGuardError);
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_MUX: '0' }, () => ({}))).toThrow(/CODEMAN_MUX/);
|
||||
});
|
||||
|
||||
it('never guesses an API URL', () => {
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: '' }, () => ({}))).toThrow(/refusing to guess/);
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: undefined }, () => ({}))).toThrow(AgentGuardError);
|
||||
});
|
||||
|
||||
it('needs its own session id to tell self from others', () => {
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_SESSION_ID: '' }, () => ({}))).toThrow(/CODEMAN_SESSION_ID/);
|
||||
});
|
||||
|
||||
it('takes the password from the environment first, the .env file second, and none means open', () => {
|
||||
expect(
|
||||
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_PASSWORD: 'file' })).auth
|
||||
).toEqual({
|
||||
username: 'admin',
|
||||
password: 'pw',
|
||||
});
|
||||
expect(resolveAgentContext(inside, () => ({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 'file' })).auth).toEqual({
|
||||
username: 'joe',
|
||||
password: 'file',
|
||||
});
|
||||
expect(resolveAgentContext(inside, () => ({})).auth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves each field the way attach and the TUI do (one shared order)', () => {
|
||||
// Password from the environment, username from the file: joe, not admin.
|
||||
expect(
|
||||
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_USERNAME: 'joe' })).auth
|
||||
).toEqual({ username: 'joe', password: 'pw' });
|
||||
});
|
||||
|
||||
it('reads a hand-authored .env with quotes and export prefixes', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-agent-env-'));
|
||||
try {
|
||||
const file = join(dir, '.env');
|
||||
writeFileSync(file, '# comment\nexport CODEMAN_USERNAME="joe"\nCODEMAN_PASSWORD=\'s3cret\'\nnot a line\n');
|
||||
expect(readCodemanEnvFile(file)).toEqual({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 's3cret' });
|
||||
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invariant 2: send is printable text + \r; ESC lives only in interrupt
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send transmits printable text only', () => {
|
||||
it('refuses every control byte and DEL, naming it', () => {
|
||||
expect(inputRefusal('\u0003')).toMatch(/0x03/); // Ctrl+C: opencode's app_exit
|
||||
expect(inputRefusal('ls\u001b')).toMatch(/0x1b/);
|
||||
expect(inputRefusal('a\u007fb')).toMatch(/0x7f/);
|
||||
expect(inputRefusal('two\nlines')).toMatch(/single line/); // not the ESC hint
|
||||
expect(inputRefusal('a\tb')).toMatch(/single line/);
|
||||
expect(inputRefusal('x\u009bmy')).toMatch(/0x9b/); // 8-bit CSI
|
||||
expect(inputRefusal('')).toMatch(/empty/);
|
||||
});
|
||||
|
||||
it('accepts ordinary prompts, including unicode', () => {
|
||||
expect(inputRefusal('review the diff in src/, then say DONE_4711')).toBeUndefined();
|
||||
expect(inputRefusal('prüfe die Ändërung ❯ ok')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends exactly one \\r, or nothing with --no-enter, and never anything else', () => {
|
||||
const base = { clientId: 'c', seq: 1 };
|
||||
expect(buildSendBody('hi', { ...base, enter: true }).input).toBe('hi\r');
|
||||
expect(buildSendBody('hi', { ...base, enter: false }).input).toBe('hi');
|
||||
const body = buildSendBody('hi', { ...base, enter: true, wait: 'stop,exit', waitTimeout: 5000 });
|
||||
expect(body).toEqual({ input: 'hi\r', useMux: true, clientId: 'c', seq: 1, wait: 'stop,exit', waitTimeout: 5000 });
|
||||
expect(buildSendBody('hi', { ...base, enter: true })).not.toHaveProperty('wait');
|
||||
});
|
||||
|
||||
it('interrupt is a bare ESC with no Enter', () => {
|
||||
const body = buildInterruptBody('c-interrupt', 7);
|
||||
expect(body.input).toBe('\u001b');
|
||||
expect(String(body.input)).not.toContain('\r');
|
||||
expect(body).toEqual({ input: '\u001b', useMux: true, clientId: 'c-interrupt', seq: 7 });
|
||||
});
|
||||
|
||||
it('agentSend refuses control bytes BEFORE touching the transport', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'q\u0003', enter: true })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invariant 3: rm fails closed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rm fails closed', () => {
|
||||
it('refuses an empty id, a short self id, and a prefix match in either direction', () => {
|
||||
expect(deleteRefusal(SELF, '')).toMatch(/empty/);
|
||||
expect(deleteRefusal('058ee7', OTHER)).toMatch(/too short/);
|
||||
expect(deleteRefusal(SELF, SELF)).toMatch(/is me/);
|
||||
expect(deleteRefusal(SELF, SELF.slice(0, 8))).toMatch(/is me/); // 8-char form of me
|
||||
expect(deleteRefusal(SELF.slice(0, 8), SELF)).toMatch(/is me/); // Docker's truncated $SELF
|
||||
expect(deleteRefusal(SELF, OTHER)).toBeUndefined();
|
||||
expect(deleteRefusal(SELF, OTHER.slice(0, 8))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('isSelfSession treats an unprovable self as "maybe me"', () => {
|
||||
expect(isSelfSession('short', OTHER)).toBe(true);
|
||||
expect(isSelfSession(SELF, '')).toBe(true);
|
||||
expect(isSelfSession(SELF, OTHER)).toBe(false);
|
||||
});
|
||||
|
||||
it('agentRm never calls DELETE on a refusal', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentRm(deps, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('agentRm deletes a foreign id plainly (killMux default, no query)', async () => {
|
||||
const deps = fakeDeps([ok({})]);
|
||||
expect(await agentRm(deps, { id: OTHER })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({ method: 'DELETE', path: `/api/v1/sessions/${OTHER}` });
|
||||
expect(deps.calls[0].query).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Verbs against the fake transport
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('agent ls', () => {
|
||||
const sessions = [
|
||||
{ id: SELF, mode: 'claude', status: 'busy', name: 'w1-Codeman' },
|
||||
{ id: OTHER, mode: 'opencode', status: 'idle', workingDir: '/home/joe/wiki' },
|
||||
];
|
||||
|
||||
it('marks this session and falls back to workingDir for the name', async () => {
|
||||
const deps = fakeDeps([ok(sessions)]);
|
||||
expect(await agentLs(deps)).toBe(EXIT.ok);
|
||||
const text = deps.out.join('\n');
|
||||
expect(text).toMatch(/\*\s+058ee7b5\s+claude\s+busy\s+w1-Codeman/);
|
||||
expect(text).toMatch(/94990c6d\s+opencode\s+idle\s+\/home\/joe\/wiki/);
|
||||
});
|
||||
|
||||
it('--json is the envelope data plus a self flag', async () => {
|
||||
const deps = fakeDeps([ok(sessions)], true);
|
||||
await agentLs(deps);
|
||||
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; self: boolean }>;
|
||||
expect(parsed.map((s) => [s.id.slice(0, 8), s.self])).toEqual([
|
||||
['058ee7b5', true],
|
||||
['94990c6d', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces a plain-text 401 as a credentials hint, not a parse error', async () => {
|
||||
const deps = fakeDeps([{ status: 401, text: 'Unauthorized' }]);
|
||||
expect(await agentLs(deps)).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/401.*password/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent send', () => {
|
||||
it('refuses to type into its own composer', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentSend(deps, { id: SELF.slice(0, 8), text: 'hi', enter: true })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('fire-and-forget: input + \\r, a fixed clientId per caller, seq from the clock', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: true })]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'say DONE_1', enter: true })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${OTHER}/input`,
|
||||
body: { input: 'say DONE_1\r', useMux: true, clientId: 'codeman-agent-cli-058ee7b5', seq: 1_700_000_000_000 },
|
||||
});
|
||||
expect(deps.calls[0].body).not.toHaveProperty('wait');
|
||||
});
|
||||
|
||||
it('--wait passes the signal list and timeout through and maps the result to an exit code', async () => {
|
||||
const stop = fakeDeps([ok({ delivered: true, wait: { signal: 'stop', timedOut: false } })]);
|
||||
expect(await agentSend(stop, { id: OTHER, text: 'go', enter: true, wait: 'stop,exit', timeoutMs: 5000 })).toBe(
|
||||
EXIT.ok
|
||||
);
|
||||
expect(stop.calls[0].body).toMatchObject({ wait: 'stop,exit', waitTimeout: 5000 });
|
||||
|
||||
const timeout = fakeDeps([ok({ delivered: true, wait: { timedOut: true, timeoutMs: 5000 } })]);
|
||||
expect(await agentSend(timeout, { id: OTHER, text: 'go', enter: true, wait: true, timeoutMs: 5000 })).toBe(
|
||||
EXIT.timeout
|
||||
);
|
||||
|
||||
const dead = fakeDeps([ok({ delivered: true, wait: { signal: 'exit' } })]);
|
||||
expect(await agentSend(dead, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
|
||||
});
|
||||
|
||||
it('delivered:false without duplicate is "the bytes went nowhere": exit 3, never a ✓', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: false, duplicate: false, wait: { ended: true, signal: null } })]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
|
||||
expect(deps.out.join('')).not.toMatch(/delivered to/);
|
||||
expect(deps.err.join('')).toMatch(/not delivered.*restart/);
|
||||
});
|
||||
|
||||
it('fire-and-forget says "accepted", not "delivered" (the route answers before the write)', async () => {
|
||||
const deps = fakeDeps([ok({})]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
|
||||
expect(deps.out.join('')).toMatch(/accepted for/);
|
||||
expect(deps.out.join('')).not.toMatch(/delivered to/);
|
||||
});
|
||||
|
||||
it('reports a tagged duplicate instead of claiming delivery', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
|
||||
await agentSend(deps, { id: OTHER, text: 'go', enter: true });
|
||||
expect(deps.out.join('')).toMatch(/duplicate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent wait', () => {
|
||||
it('--until goes to /wait and a 400 for a hook-less mode is passed through, not papered over', async () => {
|
||||
const deps = fakeDeps([apiError(400, 'INVALID_INPUT', 'until=stop is not available for mode opencode')]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${OTHER}/wait`,
|
||||
query: { until: 'stop', timeout: 1000 },
|
||||
});
|
||||
expect(deps.err.join('')).toMatch(/INVALID_INPUT.*opencode/);
|
||||
});
|
||||
|
||||
it('--match goes to /wait-output with from=buffer by default', async () => {
|
||||
const deps = fakeDeps([ok({ wait: { matched: true, match: 'DONE_1', snippet: 'DONE_1' } })]);
|
||||
expect(await agentWait(deps, { id: OTHER, match: 'DONE_1', timeoutMs: 1000 })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
path: `/api/v1/sessions/${OTHER}/wait-output`,
|
||||
query: { match: 'DONE_1', from: 'buffer', timeout: 1000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses --until together with --match', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'idle', match: 'x', timeoutMs: 1000 })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('a wait that ended without an answer is reported as dead, not as `signal: null`', async () => {
|
||||
const deps = fakeDeps([ok({ wait: { ended: true, signal: null, timedOut: false } })]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.dead);
|
||||
expect(deps.out.join('')).toMatch(/went away/);
|
||||
expect(deps.out.join('')).not.toMatch(/signal: null/);
|
||||
});
|
||||
|
||||
it('exit codes: matched/signal 0, timeout 2, exit 3', () => {
|
||||
expect(waitExitCode({ signal: 'stop' })).toBe(EXIT.ok);
|
||||
expect(waitExitCode({ matched: true })).toBe(EXIT.ok);
|
||||
expect(waitExitCode({ matched: false, timedOut: true })).toBe(EXIT.timeout);
|
||||
expect(waitExitCode({ timedOut: true })).toBe(EXIT.timeout);
|
||||
expect(waitExitCode({ signal: 'exit' })).toBe(EXIT.dead);
|
||||
// A worker that dies during --until stop: the registry only satisfies waiters that
|
||||
// listed `exit`, then cancels the rest → ended:true, signal:null. Never "done".
|
||||
expect(waitExitCode({ ended: true, signal: null, timedOut: false })).toBe(EXIT.dead);
|
||||
expect(waitExitCode({ ended: true, matched: false, timedOut: false })).toBe(EXIT.dead);
|
||||
expect(waitExitCode(undefined)).toBe(EXIT.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent read', () => {
|
||||
it('defaults to last-response and prints the text', async () => {
|
||||
const deps = fakeDeps([ok({ text: 'the answer', timestamp: 't' })]);
|
||||
expect(await agentRead(deps, { id: OTHER })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0].path).toBe(`/api/v1/sessions/${OTHER}/last-response`);
|
||||
expect(deps.out).toEqual(['the answer']);
|
||||
});
|
||||
|
||||
it('says why an empty transcript is empty instead of printing nothing', async () => {
|
||||
const deps = fakeDeps([ok({ text: '' })]);
|
||||
await agentRead(deps, { id: OTHER });
|
||||
expect(deps.err.join('')).toMatch(/nothing answered yet.*--tail 3000/);
|
||||
});
|
||||
|
||||
it('--tail fetches the terminal and strips ANSI', async () => {
|
||||
const deps = fakeDeps([ok({ terminalBuffer: '\u001b[32m❯\u001b[0m ready \u001b(B' })]);
|
||||
expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
|
||||
expect(deps.out).toEqual(['❯ ready ']);
|
||||
});
|
||||
|
||||
it('--full prints every message with its role', async () => {
|
||||
const deps = fakeDeps([
|
||||
ok({
|
||||
text: 'b',
|
||||
messages: [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', text: 'b' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await agentRead(deps, { id: OTHER, full: true });
|
||||
expect(deps.calls[0].query).toMatchObject({ context: 'full' });
|
||||
expect(deps.out.map(stripAnsi)).toEqual(['user: a', 'assistant: b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent interrupt', () => {
|
||||
it('sends the bare ESC body under its own clientId, never to itself', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: true })]);
|
||||
expect(await agentInterrupt(deps, { id: OTHER })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0].body).toEqual({
|
||||
input: '\u001b',
|
||||
useMux: true,
|
||||
clientId: 'codeman-agent-cli-058ee7b5-interrupt',
|
||||
seq: 1_700_000_000_000,
|
||||
});
|
||||
const self = fakeDeps([]);
|
||||
expect(await agentInterrupt(self, { id: SELF })).toBe(EXIT.refused);
|
||||
expect(self.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send takes the prompt as ONE argument', () => {
|
||||
it('refuses several words, which an unquoted multi-line $(…) becomes after word splitting', () => {
|
||||
expect(sendPromptFromArgs(['review src/, then say DONE'])).toEqual({ text: 'review src/, then say DONE' });
|
||||
expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
|
||||
error: expect.stringMatching(/ONE argument, got 4/),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent spawn: a worker that dies during the readiness wait', () => {
|
||||
it('is exit 3 with its own line, not the composer-timeout hint', async () => {
|
||||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { ended: true, matched: false } })]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.dead);
|
||||
expect(deps.err.join('')).toMatch(/exited during the readiness wait/);
|
||||
expect(deps.err.join('')).not.toMatch(/composer not seen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent wait: a timeout is an answer, not a failure', () => {
|
||||
it('prints a neutral line and exits 2', async () => {
|
||||
const deps = fakeDeps([ok({ wait: { timedOut: true, timeoutMs: 1000 } })]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 })).toBe(EXIT.timeout);
|
||||
const line = deps.out.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
||||
expect(line).toBe('timed out after 1000 ms (exit 2)');
|
||||
expect(deps.err).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent spawn', () => {
|
||||
it('quick-starts with lineage and waits for the claude composer', async () => {
|
||||
const deps = fakeDeps([
|
||||
ok({ sessionId: OTHER, caseName: 'scratch-1', casePath: '/x' }),
|
||||
ok({ wait: { matched: true } }),
|
||||
]);
|
||||
expect(await agentSpawn(deps, { caseName: 'scratch-1', mode: 'claude', ready: true, timeoutMs: 2000 })).toBe(
|
||||
EXIT.ok
|
||||
);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/api/v1/quick-start',
|
||||
body: { caseName: 'scratch-1', mode: 'claude', parentSessionId: SELF },
|
||||
});
|
||||
expect(deps.calls[1]).toMatchObject({
|
||||
path: `/api/v1/sessions/${OTHER}/wait-output`,
|
||||
query: { match: 'shift+tab', from: 'buffer', timeout: 2000 },
|
||||
});
|
||||
expect(deps.out).toEqual([OTHER]); // stdout is the id ALONE, so `SID=$(…)` works; prose goes to stderr
|
||||
expect(deps.err.join('')).toMatch(/spawned .*composer up/s);
|
||||
});
|
||||
|
||||
it('labels the case as agent scratch on the spawn request, and on no other request', async () => {
|
||||
// The label drives a recursive-delete affordance in the Add Case UI: it may only ride
|
||||
// the request that can CREATE a case directory (quick-start), never anything else.
|
||||
const spawn = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: true } })]);
|
||||
await agentSpawn(spawn, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 });
|
||||
expect(spawn.calls[0].headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-agent-cli' });
|
||||
expect(spawn.calls[1].headers?.['X-Codeman-Agent-Origin']).toBeUndefined(); // the readiness wait
|
||||
|
||||
const everyOther = fakeDeps((o) =>
|
||||
o.path === '/api/v1/sessions' ? ok([]) : ok({ wait: { signal: 'stop' }, text: '' })
|
||||
);
|
||||
await agentLs(everyOther);
|
||||
await agentSend(everyOther, { id: OTHER, text: 'hi', enter: true });
|
||||
await agentWait(everyOther, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 });
|
||||
await agentRead(everyOther, { id: OTHER });
|
||||
await agentInterrupt(everyOther, { id: OTHER });
|
||||
await agentRm(everyOther, { id: OTHER });
|
||||
expect(everyOther.calls.length).toBeGreaterThan(5);
|
||||
for (const call of everyOther.calls) expect(call.headers?.['X-Codeman-Agent-Origin'], call.path).toBeUndefined();
|
||||
expect(baseHeaders(ctx())).not.toHaveProperty('X-Codeman-Agent-Origin');
|
||||
});
|
||||
|
||||
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
|
||||
expect(composerReadyMark('claude')).toBe('shift+tab');
|
||||
expect(composerReadyMark('deepseek')).toBe('❯');
|
||||
expect(composerReadyMark('pi')).toBeUndefined();
|
||||
expect(composerReadyMark('no-such-cli')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a composer that never shows up is exit 2 and the session is left for inspection, not deleted', async () => {
|
||||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: false, timedOut: true } })]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.timeout);
|
||||
expect(deps.calls.map((c) => c.method)).toEqual(['POST', 'GET']);
|
||||
expect(deps.err.join('')).toMatch(/startup dialog/);
|
||||
});
|
||||
|
||||
it('a mode without a readiness mark returns after the create, and --no-ready skips the wait everywhere', async () => {
|
||||
const pi = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
|
||||
expect(await agentSpawn(pi, { caseName: 'c', mode: 'pi', ready: true, timeoutMs: 1000 })).toBe(EXIT.ok);
|
||||
expect(pi.calls).toHaveLength(1);
|
||||
const noReady = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
|
||||
expect(await agentSpawn(noReady, { caseName: 'c', mode: 'claude', ready: false, timeoutMs: 1000 })).toBe(EXIT.ok);
|
||||
expect(noReady.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a failed readiness call reports its own reason instead of the trust-dialog hint', async () => {
|
||||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), apiError(429, 'RATE_LIMITED', 'waiter pool full')]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/readiness check failed: RATE_LIMITED/);
|
||||
expect(deps.err.join('')).not.toMatch(/trust dialog/);
|
||||
expect(deps.out).toEqual([OTHER]); // the session exists; the id is still handed back
|
||||
});
|
||||
|
||||
it('a failed quick-start is terminal: the error code is shown and nothing else is called', async () => {
|
||||
const deps = fakeDeps([apiError(409, 'SESSION_BUSY', 'session cap reached')]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.calls).toHaveLength(1);
|
||||
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session id prefixes', () => {
|
||||
const THIRD = '94990c6d-ffff-4000-8000-000000000000';
|
||||
const list = ok([{ id: SELF }, { id: OTHER }, { id: THIRD }]);
|
||||
|
||||
it('a full id goes straight to the route, no list call', async () => {
|
||||
const deps = fakeDeps([ok({ text: 'x' })]);
|
||||
await agentRead(deps, { id: OTHER });
|
||||
expect(deps.calls.map((c) => c.path)).toEqual([`/api/v1/sessions/${OTHER}/last-response`]);
|
||||
});
|
||||
|
||||
it('a unique prefix (what `ls` prints) resolves through the list — the routes 404 on prefixes', async () => {
|
||||
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ text: 'x' })]);
|
||||
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.ok);
|
||||
expect(deps.calls.map((c) => c.path)).toEqual(['/api/v1/sessions', `/api/v1/sessions/${OTHER}/last-response`]);
|
||||
});
|
||||
|
||||
it('an ambiguous prefix refuses instead of picking one', async () => {
|
||||
const deps = fakeDeps([list]);
|
||||
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/ambiguous.*94990c6d-e461.*94990c6d-ffff/);
|
||||
expect(deps.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an unknown prefix names the problem', async () => {
|
||||
const deps = fakeDeps([list]);
|
||||
expect(await agentRm(deps, { id: 'deadbeef' })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/no session starts with "deadbeef"/);
|
||||
expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
|
||||
});
|
||||
|
||||
it('rm runs the self guard before the list and again on the resolved id', async () => {
|
||||
const first = fakeDeps([ok([{ id: SELF }])]);
|
||||
expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
|
||||
expect(first.calls).toEqual([]); // refused before any request
|
||||
const resolved = fakeDeps([ok([{ id: SELF }, { id: OTHER }])]);
|
||||
expect(await agentRm(resolved, { id: 'deadbeef' })).toBe(EXIT.error); // nothing to delete
|
||||
expect(resolved.calls.map((c) => c.method)).toEqual(['GET']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('option parsing', () => {
|
||||
it('positive integers only, the server rejects the rest', () => {
|
||||
expect(parsePositiveInt(undefined, 60000)).toBe(60000);
|
||||
expect(parsePositiveInt('1500', 1)).toBe(1500);
|
||||
for (const bad of ['0', '-1', '1.5', '30s', '']) expect(() => parsePositiveInt(bad, 1)).toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
it('describeFailure prefers the envelope and falls back to the status line', () => {
|
||||
expect(describeFailure(apiError(404, 'NOT_FOUND', 'no such session'))).toBe(
|
||||
'NOT_FOUND: no such session (HTTP 404)'
|
||||
);
|
||||
expect(describeFailure({ status: 403, text: 'Forbidden: host not allowed' })).toBe(
|
||||
'HTTP 403 Forbidden: host not allowed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The real transport against a local server
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('httpRequest', () => {
|
||||
let server: http.Server;
|
||||
let apiUrl: string;
|
||||
const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
|
||||
if (req.url?.startsWith('/plain')) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: true, data: { echo: true } }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address() as { port: number };
|
||||
apiUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('carries lineage headers, Basic auth and a urlencoded query (the + in shift+tab survives)', async () => {
|
||||
const res = await httpRequest(ctx({ apiUrl, auth: { username: 'joe', password: 'pw' } }), {
|
||||
method: 'GET',
|
||||
path: '/api/v1/sessions/x/wait-output',
|
||||
query: { match: 'shift+tab', from: 'buffer', timeout: 1000, nocase: undefined },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json).toEqual({ success: true, data: { echo: true } });
|
||||
const last = seen.at(-1)!;
|
||||
expect(last.url).toBe('/api/v1/sessions/x/wait-output?match=shift%2Btab&from=buffer&timeout=1000');
|
||||
expect(last.headers['x-codeman-parent-session']).toBe(SELF);
|
||||
expect(last.headers['x-codeman-agent-origin']).toBeUndefined(); // only spawn's quick-start carries it
|
||||
expect(last.headers.authorization).toBe(`Basic ${Buffer.from('joe:pw').toString('base64')}`);
|
||||
});
|
||||
|
||||
it('posts JSON bodies with a length, and no auth header when the server is open', async () => {
|
||||
await httpRequest(ctx({ apiUrl }), {
|
||||
method: 'POST',
|
||||
path: '/api/v1/sessions/x/input',
|
||||
body: { input: 'hi\r', seq: 1 },
|
||||
});
|
||||
const last = seen.at(-1)!;
|
||||
expect(last.method).toBe('POST');
|
||||
expect(JSON.parse(last.body)).toEqual({ input: 'hi\r', seq: 1 });
|
||||
expect(last.headers['content-type']).toBe('application/json');
|
||||
expect(last.headers.authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a plain-text body when the answer is not JSON', async () => {
|
||||
const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.json).toBeUndefined();
|
||||
expect(res.text).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
|
||||
const TOP_LEVEL: Record<string, string[]> = {
|
||||
attach: [],
|
||||
skill: [],
|
||||
agent: [],
|
||||
session: ['s'],
|
||||
task: ['t'],
|
||||
ralph: ['r'],
|
||||
@@ -53,6 +54,7 @@ const SUBCOMMANDS: Record<string, Record<string, string[]>> = {
|
||||
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
|
||||
ralph: { start: [], stop: [], status: [] },
|
||||
skill: { install: [], uninstall: [] },
|
||||
agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
|
||||
service: { install: [], uninstall: [], status: [] },
|
||||
users: { add: [], passwd: [], list: ['ls'], rm: [] },
|
||||
};
|
||||
|
||||
@@ -155,6 +155,37 @@ describe('agent-created case marker', () => {
|
||||
expect(await readMarker(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('never labels the working directory of a POST /api/sessions, header or not', async () => {
|
||||
// `codeman agent` sends the origin only on quick-start, but the server must not
|
||||
// trust that: a session created on an EXISTING workingDir (somebody's real repo)
|
||||
// is never marked as agent scratch, whatever the request carries.
|
||||
const name = 'existingrepo1';
|
||||
created.push(name);
|
||||
const dir = join(getCasesDir(), name);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
headers: { 'x-codeman-agent-origin': 'codeman-agent-cli', 'x-codeman-parent-session': PARENT_ID },
|
||||
payload: { workingDir: dir, mode: 'claude' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(await readMarker(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('labels a case quick-start creates for `codeman agent spawn`, and not one that existed', async () => {
|
||||
await quickStart('agentclicase1', { headers: { 'x-codeman-agent-origin': 'codeman-agent-cli' } });
|
||||
expect(await readMarker('agentclicase1')).toMatchObject({ createdBy: 'codeman-agent-cli' });
|
||||
|
||||
const name = 'preexisting2';
|
||||
created.push(name);
|
||||
await mkdir(join(getCasesDir(), name), { recursive: true });
|
||||
await quickStart(name, { headers: { 'x-codeman-agent-origin': 'codeman-agent-cli' } });
|
||||
expect(await readMarker(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('drops an unrecognised origin token rather than storing it', async () => {
|
||||
await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } });
|
||||
|
||||
|
||||
Reference in new issue
Block a user