mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
fix(cli): review — 8-char id floor, marker echo, wake answers on send
From the review of #557: - An id shorter than 8 characters refuses with exit 4 before any request, on every verb. `rm 9` resolved to whichever session was alone with that first character (the user's own tab included) and deleted it. Same floor as the server's PARENT_SESSION_ID_MIN_PREFIX. `rm` no longer claims a lineage check: "Delete any session except this one". - `wait --match` help and the README example say the marker must not appear verbatim in the prompt (its echo matches at once) and show the split form. - `send` reads the route's wake-on-LAN answers: `buffered` gets its own line (exit 0), `dropped` exits 1 instead of printing "accepted". - `--` for a prompt that starts with "-", in the `send` description and in the one-argument refusal. - `stripAnsi` builds on the shared one (OSC sequences go too); the inputRefusal JSDoc sits above inputRefusal again. - docs/wiki/Driving-Codeman-From-An-Agent.md gets a `codeman agent` section. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0481db569d
commit
24e51a3b8e
@@ -954,12 +954,13 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent wait "$SID" --match DONE_4711 # marker wait for hook-less modes (opencode, pi, …)
|
||||
codeman agent 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" # refuses your own id
|
||||
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). 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.
|
||||
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)
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
`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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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 same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
||||
|
||||
+74
-23
@@ -19,6 +19,9 @@
|
||||
* 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.
|
||||
@@ -37,6 +40,7 @@ import {
|
||||
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
|
||||
@@ -112,11 +116,6 @@ export function deleteRefusal(selfId: string, id: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. The composer
|
||||
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
|
||||
* DEL) reaches the pane as a keypress. None of that is a prompt.
|
||||
*/
|
||||
/**
|
||||
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
|
||||
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
|
||||
@@ -125,10 +124,15 @@ export function deleteRefusal(selfId: string, id: string): string | undefined {
|
||||
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> "…"\`)`,
|
||||
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
|
||||
@@ -175,10 +179,13 @@ export function defaultClientId(selfId: string, suffix = ''): string {
|
||||
return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
|
||||
}
|
||||
|
||||
/** Strip ANSI CSI/charset sequences from a terminal buffer (GNU and BSD alike). */
|
||||
/**
|
||||
* 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 text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
|
||||
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
|
||||
}
|
||||
|
||||
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
|
||||
@@ -368,21 +375,38 @@ interface SessionRow {
|
||||
/** 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.
|
||||
* 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 }> {
|
||||
if (!id) return { error: 'refusing: empty session id' };
|
||||
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) };
|
||||
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\`)` };
|
||||
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}` };
|
||||
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. */
|
||||
@@ -529,7 +553,7 @@ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<
|
||||
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);
|
||||
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),
|
||||
@@ -544,8 +568,24 @@ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<
|
||||
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as { delivered?: boolean; duplicate?: boolean; wait?: WaitResult } | undefined;
|
||||
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.
|
||||
@@ -561,6 +601,12 @@ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<
|
||||
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 {
|
||||
@@ -608,7 +654,7 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<
|
||||
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);
|
||||
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, {
|
||||
@@ -653,7 +699,7 @@ export interface ReadOptions {
|
||||
/** `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);
|
||||
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, {
|
||||
@@ -699,7 +745,7 @@ export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<
|
||||
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);
|
||||
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',
|
||||
@@ -722,7 +768,7 @@ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise
|
||||
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
|
||||
if (refusal) return fail(deps, refusal, EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error);
|
||||
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);
|
||||
@@ -814,7 +860,9 @@ export function registerAgentCommands(program: Command): Command {
|
||||
|
||||
agent
|
||||
.command('send <id> <text...>')
|
||||
.description('Type a prompt into another session and press Enter (ONE quoted argument, printable text only)')
|
||||
.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))
|
||||
@@ -863,7 +911,10 @@ export function registerAgentCommands(program: Command): Command {
|
||||
'-u, --until <signals>',
|
||||
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
|
||||
)
|
||||
.option('-m, --match <marker>', 'Literal substring to wait for in the output (ANSI-stripped, no regex)')
|
||||
.option(
|
||||
'-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)')
|
||||
@@ -921,7 +972,7 @@ export function registerAgentCommands(program: Command): Command {
|
||||
|
||||
agent
|
||||
.command('rm <id>')
|
||||
.description('Delete a session you created (refuses your own 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 })));
|
||||
|
||||
|
||||
+66
-1
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -29,7 +30,9 @@ import {
|
||||
httpRequest,
|
||||
inputRefusal,
|
||||
isSelfSession,
|
||||
MIN_ID_PREFIX_LENGTH,
|
||||
parsePositiveInt,
|
||||
registerAgentCommands,
|
||||
resolveAgentContext,
|
||||
stripAnsi,
|
||||
waitExitCode,
|
||||
@@ -300,6 +303,24 @@ describe('agent send', () => {
|
||||
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 });
|
||||
@@ -370,7 +391,7 @@ describe('agent read', () => {
|
||||
});
|
||||
|
||||
it('--tail fetches the terminal and strips ANSI', async () => {
|
||||
const deps = fakeDeps([ok({ terminalBuffer: '\u001b[32m❯\u001b[0m ready \u001b(B' })]);
|
||||
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 ']);
|
||||
@@ -414,6 +435,8 @@ describe('send takes the prompt as ONE argument', () => {
|
||||
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') });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -549,6 +572,29 @@ describe('session id prefixes', () => {
|
||||
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);
|
||||
@@ -559,6 +605,25 @@ describe('session id prefixes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user