Merge pull request #557: feat(cli): codeman agent — session-to-session verbs for every CLI mode (#445, phase 1)

This commit is contained in:
Codeman maintainer
2026-10-10 02:53:28 +02:00
17 changed files with 1963 additions and 76 deletions
+2
View File
@@ -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 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-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`. **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`.
+18
View File
@@ -940,6 +940,24 @@ codeman tui --list # numbered session list (plain tex
codeman tui 3 # attach to session 3 of that list 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 send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
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, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). 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) ### 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. 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.
+4
View File
@@ -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). | | `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. | | `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`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. 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`) ## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a A create request may name the session that spawned it, which the web UI draws as a
+32 -1
View File
@@ -4,7 +4,8 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
supervising other sessions.** supervising other sessions.**
Two routes. Start with the skill. Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
`codeman agent` commands. Raw HTTP is there for everything else.
## The agent skill ## The agent skill
@@ -59,6 +60,36 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals. completion signals.
## The `codeman agent` commands
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
ownership apply unchanged, and it refuses to act outside a Codeman session. 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
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 send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
ambiguous prefix.
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
prompt is output too, so ask for the marker in halves and wait on the joined form.
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
worker exited, `4` refused. `--json` prints the response's `data`.
## The manual path ## The manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
+6
View File
@@ -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 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). 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 ## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
+6
View File
@@ -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 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). 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 ## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
+980
View File
@@ -0,0 +1,980 @@
/**
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
* inside a Codeman session, in every CLI mode.
*
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
* preamble — has the same verbs from one line in its AGENTS.md.
*
* Invariants (each asserted in `test/cli-agent.test.ts`):
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
* never guesses a URL — a server you are not part of is not yours to drive.
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
* `interrupt`, which never appends `\r`. A stray control byte is a dead session
* in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
* direction refuses. Ids appear in full and 8-char form, so equality alone
* misses a real combination — and the miss deletes the caller.
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
* verb. `9` would resolve to whichever session is alone with that first
* character, the user's own interactive tab included.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
*
* @module cli-agent
*/
import http from 'node:http';
import https from 'node:https';
import type { Command } from 'commander';
import {
basicAuthHeader,
credentialsFrom,
readCodemanEnvFile,
type CodemanCredentials,
} from './codeman-credentials.js';
import { getCli } from './config/cli-registry/registry.js';
import { GLYPH, palette, table } from './cli-style.js';
import { getErrorMessage } from './types.js';
import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
// ─────────────────────────────────────────────────────────────────────────────
// Context and guard
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentContext {
/** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
apiUrl: string;
/** This session's id, from `CODEMAN_SESSION_ID`. */
selfId: string;
/** Basic-auth credentials, when the server has a password. */
auth?: CodemanCredentials;
}
/** Thrown when the process is not inside a Codeman-managed session. */
export class AgentGuardError extends Error {}
/** Exit codes shared by every verb; a shell agent can branch on them. */
export const EXIT = {
ok: 0,
error: 1,
timeout: 2,
dead: 3,
refused: 4,
} as const;
/**
* Resolve the context from the environment, or throw `AgentGuardError`.
*
* Credentials in the order every client of the API uses (`credentialsFrom`, shared
* with `codeman attach` and the TUI): each field from the environment (a session
* inherits the server's), then the data dir's `.env`. No password means the server
* is open (single-user) — or it is not, and the 401 says so.
*/
export function resolveAgentContext(
env: NodeJS.ProcessEnv = process.env,
envFile: () => Record<string, string> = readCodemanEnvFile
): AgentContext {
if (env.CODEMAN_MUX !== '1') {
throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
}
const apiUrl = env.CODEMAN_API_URL?.trim();
if (!apiUrl) {
throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
}
const selfId = env.CODEMAN_SESSION_ID?.trim();
if (!selfId) {
throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
}
const credentials = credentialsFrom(env, envFile());
return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
}
// ─────────────────────────────────────────────────────────────────────────────
// Pure helpers (the invariants)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Is `id` this session? Prefix in BOTH directions, because ids appear in full and
* in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
* shorter than 8 characters cannot prove anything and is treated as "maybe me".
*/
export function isSelfSession(selfId: string, id: string): boolean {
if (!id || selfId.length < 8) return true;
return id.startsWith(selfId) || selfId.startsWith(id);
}
/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
export function deleteRefusal(selfId: string, id: string): string | undefined {
if (!id) return 'refusing: empty session id';
if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
return undefined;
}
/**
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
* back into a single line, so the multi-line refusal below would never see it.
*/
export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return {
error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send <id> "…"\`; a prompt that starts with "-" goes after --: \`send <id> -- "- fix the bug"\`)`,
};
}
/**
* Why `send` refuses this text, or `undefined` when it is printable. The composer
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
* DEL) reaches the pane as a keypress. None of that is a prompt.
*/
export function inputRefusal(text: string): string | undefined {
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <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 a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
* titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
*/
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${flag} must be a positive integer, got "${raw}"`);
}
return n;
}
/**
* Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
* timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
* during `--until stop` comes back as `ended:true, signal:null` (the registry only
* satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
* a dead worker as `ended:true, matched:false` — both are "dead", never "done".
*/
export function waitExitCode(wait: WaitResult | undefined): number {
if (!wait) return EXIT.error;
if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
if (wait.timedOut) return EXIT.timeout;
if (wait.matched === false) return EXIT.timeout;
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// HTTP
// ─────────────────────────────────────────────────────────────────────────────
export interface ApiEnvelope<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;
/**
* Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
* the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
*/
export const MIN_ID_PREFIX_LENGTH = 8;
/**
* Turn the id a human typed into the one the routes accept. `ls` prints 8-char
* prefixes and the routes answer 404 to those (measured live), so anything shorter
* than a full id resolves through the session list; an ambiguous prefix refuses
* rather than picking one. Below 8 characters it refuses before the list: "unique"
* means nothing for `9` — it names whatever session happens to be alone with that
* first character, and `rm`/`send` would act on it.
*/
export async function resolveSessionId(
deps: AgentDeps,
id: string
): Promise<{ id: string } | { error: string; code: number }> {
if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
if (id.length < MIN_ID_PREFIX_LENGTH) {
return {
error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
code: EXIT.refused,
};
}
if (id.length >= FULL_ID_LENGTH) return { id };
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
if (matches.length === 1) return { id: matches[0].id };
if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
export async function agentLs(deps: AgentDeps): Promise<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, target.code);
const body = buildSendBody(options.text, {
enter: options.enter,
clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
seq: options.seq ?? (deps.now ?? Date.now)(),
wait: options.wait,
waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
});
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
| undefined;
if (deps.json) emitJson(deps, data ?? {});
// Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
// holds the chunk and types it once the pane is back (`buffered`), or the chunk was
// over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
// so a retry needs a new one (the default, the clock, gives it that).
if (data?.dropped) {
if (!deps.json) {
deps.io.err(
palette.err(
`${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
)
);
}
return EXIT.error;
}
// `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
// the PTY exited or send-keys hit a dead pane. The field exists so a client does not
// say "wait longer" when the truth is "restart the worker" — so it is a failure here.
if (data?.delivered === false && !data.duplicate) {
if (!deps.json) {
deps.io.err(
palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
);
}
return EXIT.dead;
}
if (!deps.json) {
const noEnter = options.enter ? '' : ' (no Enter)';
if (data?.duplicate) {
deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
} else if (data?.buffered) {
deps.io.out(
palette.ok(
`${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
)
);
} else if (data?.delivered === true) {
deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
} else {
// Fire-and-forget answers before the write, so there is no delivery report here.
deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
}
if (data?.wait) deps.io.out(describeWait(data.wait));
}
if (options.wait === undefined) return EXIT.ok;
return waitExitCode(data?.wait);
}
function describeWait(wait: WaitResult): string {
if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
if (wait.ended) {
return palette.err(
`${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
);
}
// A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
// so the line stays neutral instead of looking like an error to whoever reads the log.
if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
if (wait.matched !== undefined) {
return wait.matched
? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
: palette.muted('not matched (exit 2)');
}
return palette.ok(
`${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
);
}
export interface WaitOptions {
id: string;
until?: string;
match?: string;
from?: 'buffer' | 'now';
fresh?: boolean;
nocase?: boolean;
timeoutMs: number;
}
/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<number> {
if (options.until && options.match)
return fail(deps, 'use either --until <signals> or --match <marker>, not both', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
const res = options.match
? await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait-output`,
query: {
match: options.match,
from: options.from ?? 'buffer',
nocase: options.nocase ? 1 : undefined,
timeout: options.timeoutMs,
},
timeoutMs: options.timeoutMs + 10_000,
})
: await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait`,
query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A 400 here is the server saying "this mode has no such signal" (until=stop on an
// external CLI). Passed through, never papered over: the marker path is the answer.
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
if (deps.json) {
emitJson(deps, data ?? {});
} else if (data?.wait) {
deps.io.out(describeWait(data.wait));
if (data.limitPaused)
deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
}
return waitExitCode(data?.wait);
}
export interface ReadOptions {
id: string;
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
tail?: number;
/** Whole conversation (`context=full`) instead of the last assistant message. */
full?: boolean;
}
/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
if (options.tail !== undefined) {
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/terminal`,
query: { tail: options.tail },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
if (deps.json) emitJson(deps, res.json.data);
else deps.io.out(stripAnsi(buffer));
return EXIT.ok;
}
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/last-response`,
query: { context: options.full ? 'full' : undefined },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
| undefined;
if (deps.json) {
emitJson(deps, data ?? {});
return EXIT.ok;
}
if (options.full && data?.messages) {
for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
return EXIT.ok;
}
const text = data?.text ?? '';
if (!text) {
deps.io.err(
palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
);
return EXIT.ok;
}
deps.io.out(text);
return EXIT.ok;
}
/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else
deps.io.out(
palette.ok(
`${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
)
);
return EXIT.ok;
}
/** `agent rm` — delete a session that is provably not this one. */
export async function agentRm(deps: AgentDeps, options: { id: string }): Promise<number> {
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
// to me only if the list is lying, but a delete is the one call worth the paranoia.
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
const res = await deps.request(deps.ctx, {
method: 'DELETE',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// 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; a prompt that starts with "-" goes after --: send <id> -- "- fix the bug")'
)
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
.option('-u, --until <signals>', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
.option('--no-enter', 'Type the text without submitting it')
.option('--client-id <id>', 'Exactly-once tag (default: one per calling session)')
.option(
'--seq <n>',
'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
)
.option('--json', 'Machine-readable output')
.action(
(
id: string,
words: string[],
options: {
wait?: boolean;
until?: string;
timeout?: string;
enter: boolean;
clientId?: string;
seq?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) => {
const prompt = sendPromptFromArgs(words);
if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
return agentSend(deps, {
id,
text: prompt.text,
enter: options.enter,
wait: options.until ?? (options.wait ? true : undefined),
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
clientId: options.clientId,
seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
});
})
);
agent
.command('wait <id>')
.description(
'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/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). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
)
.option('--from <where>', 'buffer (scan existing output first, the default) or now', 'buffer')
.option('--nocase', 'Case-insensitive --match')
.option('--fresh', 'Require an actual transition (--until only)')
.option('-t, --timeout <ms>', 'Budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(
id: string,
options: {
until?: string;
match?: string;
from: string;
nocase?: boolean;
fresh?: boolean;
timeout?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) =>
agentWait(deps, {
id,
until: options.until,
match: options.match,
from: options.from === 'now' ? 'now' : 'buffer',
nocase: options.nocase,
fresh: options.fresh,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('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 any session except this one (refuses your own id)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
return agent;
}
+8 -28
View File
@@ -15,9 +15,11 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path'; import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js'; import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js'; import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js'; import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { registerAgentCommands } from './cli-agent.js';
import { getSessionManager } from './session-manager.js'; import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js'; import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js'; import { getRalphLoop } from './ralph-loop.js';
@@ -42,32 +44,8 @@ function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`; return `codeman://attach?path=${encodeURIComponent(filePath)}`;
} }
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> { async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const envFile = readCodemanEnv(); const { username, password } = readCodemanCredentials();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl); const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath }); const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http; const transport = url.protocol === 'https:' ? https : http;
@@ -252,6 +230,10 @@ skillCmd
} }
}); });
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands(program);
// ============ Session Commands ============ // ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -641,9 +623,7 @@ function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
} catch { } catch {
return Promise.resolve(null); return Promise.resolve(null);
} }
const envFile = readCodemanEnv(); const { username, password } = readCodemanCredentials();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const transport = url.protocol === 'https:' ? https : http; const transport = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = { Accept: 'application/json' }; const headers: Record<string, string> = { Accept: 'application/json' };
if (password) { if (password) {
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Credentials for a client of this Codeman instance's own API.
*
* Env first, the data dir's `.env` as the fallback — the hand-authored file
* `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
* three clients cannot drift on quoting, comments or the default username.
*
* @module codeman-credentials
*/
import { readFileSync } from 'node:fs';
import { dataPath } from './config/instance.js';
export interface CodemanCredentials {
username: string;
/** Absent when no password is configured (or only the server's environment has it). */
password?: string;
}
/**
* Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
* prefix tolerated (the file is hand-authored, often sourced by a shell too), one
* layer of matching quotes stripped, anything that is not an assignment ignored.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record<string, string> {
try {
return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
return {};
}
}
/**
* The lookup order every client uses, per field: the environment, then the `.env`
* file, then (username only) `admin`. Pure, so a caller with its own environment
* object (`codeman agent`'s guard takes one for testability) gets the same answer.
*/
export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record<string, string>): CodemanCredentials {
const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
/**
* Credentials for the API. No password means no auth is configured, or the user has
* it only in the server's environment, in which case the API answers 401.
*/
export function readCodemanCredentials(
envFilePath: string = dataPath('.env'),
env: NodeJS.ProcessEnv = process.env
): CodemanCredentials {
return credentialsFrom(env, readCodemanEnvFile(envFilePath));
}
/** `Authorization` header value, or undefined when there is no password to send. */
export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
+2
View File
@@ -320,6 +320,8 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose // A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched. // transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(), 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 workDetect: z
.object({ .object({
promptGlyph: z.string().min(1).max(8), promptGlyph: z.string().min(1).max(8),
+5
View File
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
// in them, so a copy can drop two and paste flush. Claude and codex are the only // 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. // entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2, 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 // 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` // `✻ 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. // 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 // 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. // the session can disarm the bridge, and docker/remote cannot reach it at all.
hooks: 'supervised', hooks: 'supervised',
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
composerReadyMark: '❯',
transcript: 'deepseek-zstd', transcript: 'deepseek-zstd',
altScreen: 'strip-mux-only', altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
+11
View File
@@ -416,6 +416,17 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes. * Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/ */
transcriptGutter?: number; 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). */ /** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean; requiresMux: boolean;
/** /**
+9 -47
View File
@@ -48,6 +48,12 @@ import https from 'node:https';
import { hostname as osHostname } from 'node:os'; import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js'; import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
import {
basicAuthHeader,
parseEnvFile,
readCodemanCredentials,
type CodemanCredentials,
} from '../codeman-credentials.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js'; import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js'; import { getErrorMessage } from '../types/api.js';
@@ -281,53 +287,9 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`]; return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
} }
/** // One credential reader for every client of the API (attach, tui, agent).
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
* lines and `#` comments skipped, one layer of matching quotes stripped. export type TuiCredentials = CodemanCredentials;
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
export interface TuiCredentials {
username: string;
password?: string;
}
/**
* Credentials for the API, env first and the data dir's `.env` as the fallback,
* exactly like the `codeman attach` path. No password means no auth is
* configured (or the user has it only in the server's environment, in which
* case the API answers 401 and `connect()` reports `authRequired`).
*/
export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
let fileEnv: Record<string, string> = {};
try {
fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
/* absent or unreadable: env-only */
}
const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Degraded mode // Degraded mode
+711
View File
@@ -0,0 +1,711 @@
/**
* @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 { Command } from 'commander';
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,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
registerAgentCommands,
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('a sleeping remote host: `buffered` gets its own line and exit 0, never "accepted"', async () => {
const deps = fakeDeps([ok({ buffered: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
expect(deps.out.join('')).toMatch(/buffered for .*asleep/);
expect(deps.out.join('')).not.toMatch(/accepted for/);
});
it('`dropped` (over the wake buffer cap) is a failure: exit 1, nothing claims success', async () => {
const deps = fakeDeps([ok({ buffered: true, dropped: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/dropped: .*nothing will be typed/);
expect(deps.out).toEqual([]);
const json = fakeDeps([ok({ buffered: true, dropped: true })], true);
expect(await agentSend(json, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
expect(JSON.parse(json.out.join(''))).toEqual({ buffered: true, dropped: true });
});
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]0;w1 title\u0007\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/),
});
// A leading "-" is commander's option syntax, so the hint names the escape.
expect(sendPromptFromArgs(['a', 'b'])).toMatchObject({ error: expect.stringContaining('send <id> -- "- fix') });
});
});
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('a prefix shorter than 8 characters refuses (exit 4) before any request, on every verb', async () => {
expect(MIN_ID_PREFIX_LENGTH).toBe(8);
// The maintainer's repro: `rm 9` with one other session starting with 9 deleted it.
const rm = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({})]);
expect(await agentRm(rm, { id: '9' })).toBe(EXIT.refused);
expect(rm.calls).toEqual([]);
expect(rm.err.join('')).toMatch(/"9" is shorter than 8 characters.*8-character id `agent ls` prints/);
const short = OTHER.slice(0, 7);
const verbs: Array<[string, (deps: AgentDeps) => Promise<number>]> = [
['send', (d) => agentSend(d, { id: short, text: 'go', enter: true })],
['wait', (d) => agentWait(d, { id: short, until: 'idle', timeoutMs: 1000 })],
['read', (d) => agentRead(d, { id: short })],
['interrupt', (d) => agentInterrupt(d, { id: short })],
['rm', (d) => agentRm(d, { id: short })],
];
for (const [verb, call] of verbs) {
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ delivered: true })]);
expect(await call(deps), verb).toBe(EXIT.refused);
expect(deps.calls, verb).toEqual([]);
}
});
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('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
it('--match says the prompt must not contain the marker verbatim, and how to split it', () => {
const match = sub('wait').options.find((o) => o.long === '--match')!;
expect(match.description).toMatch(/never put the marker verbatim in the prompt/);
expect(match.description).toMatch(/WORKDONE followed by _4711.*WORKDONE_4711/);
});
it('send names the -- escape for a prompt that starts with "-"', () => {
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
});
it('rm does not claim a lineage check it does not make', () => {
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
});
});
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');
});
});
+2
View File
@@ -33,6 +33,7 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
const TOP_LEVEL: Record<string, string[]> = { const TOP_LEVEL: Record<string, string[]> = {
attach: [], attach: [],
skill: [], skill: [],
agent: [],
session: ['s'], session: ['s'],
task: ['t'], task: ['t'],
ralph: ['r'], ralph: ['r'],
@@ -53,6 +54,7 @@ const SUBCOMMANDS: Record<string, Record<string, string[]>> = {
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] }, task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
ralph: { start: [], stop: [], status: [] }, ralph: { start: [], stop: [], status: [] },
skill: { install: [], uninstall: [] }, skill: { install: [], uninstall: [] },
agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
service: { install: [], uninstall: [], status: [] }, service: { install: [], uninstall: [], status: [] },
users: { add: [], passwd: [], list: ['ls'], rm: [] }, users: { add: [], passwd: [], list: ['ls'], rm: [] },
}; };
+61
View File
@@ -0,0 +1,61 @@
/**
* @fileoverview The one credential reader `codeman attach`, `codeman tui` and
* `codeman agent` share: env first, the data dir's `.env` as the fallback.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { basicAuthHeader, readCodemanCredentials, readCodemanEnvFile } from '../src/codeman-credentials.js';
describe('readCodemanCredentials', () => {
let dir: string;
const saved = { user: process.env.CODEMAN_USERNAME, pass: process.env.CODEMAN_PASSWORD };
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-cred-'));
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_PASSWORD;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (saved.user === undefined) delete process.env.CODEMAN_USERNAME;
else process.env.CODEMAN_USERNAME = saved.user;
if (saved.pass === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = saved.pass;
});
it('falls back to the .env file, quotes and an export prefix stripped, default user admin', () => {
const env = join(dir, '.env');
writeFileSync(env, '# a comment\nnot an assignment\nexport CODEMAN_PASSWORD="hunter2"\n');
expect(readCodemanCredentials(env)).toEqual({ username: 'admin', password: 'hunter2' });
});
it('prefers the environment over the file', () => {
const env = join(dir, '.env');
writeFileSync(env, 'CODEMAN_USERNAME=file\nCODEMAN_PASSWORD=file-pass\n');
process.env.CODEMAN_USERNAME = 'envuser';
process.env.CODEMAN_PASSWORD = 'env-pass';
expect(readCodemanCredentials(env)).toEqual({ username: 'envuser', password: 'env-pass' });
});
it('an absent file means no password, and no header to send', () => {
const creds = readCodemanCredentials(join(dir, 'missing'));
expect(creds).toEqual({ username: 'admin' });
expect(basicAuthHeader(creds)).toBeUndefined();
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
});
it('takes an explicit environment, field by field', () => {
const env = join(dir, '.env');
writeFileSync(env, 'CODEMAN_USERNAME=joe\n');
expect(readCodemanCredentials(env, { CODEMAN_PASSWORD: 'pw' })).toEqual({ username: 'joe', password: 'pw' });
});
it('builds a Basic header from a password', () => {
expect(basicAuthHeader({ username: 'joe', password: 'pw' })).toBe(
`Basic ${Buffer.from('joe:pw').toString('base64')}`
);
});
});
@@ -155,6 +155,37 @@ describe('agent-created case marker', () => {
expect(await readMarker(name)).toBeNull(); 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 () => { it('drops an unrecognised origin token rather than storing it', async () => {
await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } }); await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } });