Merge pull request #694: feat(agent): COD-529 add bounded inspection and guarded submission

Conflicts resolved by keeping both sides:
- CLAUDE.md (Agent CLI): #694's opening (an HTTP client, the experimental
  agent-session-routes behind agentCoordinationEnabled) with #708's clause
  on the directory spawn and its two refusals.
- docs/api-reference.md (codeman agent): #708's spawn paragraph and --json
  shape, with #694's inspect/stage/submit in the verb list and the guarded
  endpoints named before "adds no route for the other verbs".
- src/cli-agent.ts (@fileoverview): #708's quick-start / directory spawn
  routes and #694's experimental routes in one endpoint list; the two
  node:fs/promises imports (#708's realpath+stat, #694's open+stat) merged
  into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 02:14:44 +01:00
commit 5908cc51fd
50 files changed
+5408 -116

No files matched your search

+29
View File
@@ -384,6 +384,35 @@ const capabilitiesSchema = z
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
// Same guard again: run over the rows under the composer each time guarded
// input reads the pane, and ~/.codeman/clis.json can set it.
composerFooterLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the rows just above the composer by the same reads.
composerPopupLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Single ASCII punctuation characters, never letters, digits or whitespace: each one
// refuses every message that starts (or whose last word starts) with it.
composerCommandPrefixes: z
.string()
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters')
.optional(),
composerCompletionSigils: z
.string()
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters')
.optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
+37 -1
View File
@@ -13,6 +13,14 @@
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
/**
* Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what
* codex prints when no effort is configured), as one regex alternation. Shared by the two
* patterns that read that footer (`modelDetect.screenLine` and
* `workDetect.composerFooterLine`), so a new level is a one-place edit.
*/
const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|');
const HOME_DIRS = {
local: '~/.local/bin',
usrLocal: '/usr/local/bin',
@@ -734,6 +742,34 @@ const CODEX: CliEntry = {
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
// The rows codex draws under an idle composer, tested one at a time by guarded
// stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default ·
// ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the
// `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older
// `NN% context left` footer. Indented, so the `›` composer row itself never matches.
composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`,
// Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/
// popup_state.rs, composer_layout.rs and the popups' render code and snapshots):
// every suggestion popup renders ABOVE the composer and keeps the footer below it,
// so the footer alone cannot tell `› /stat` with the command list open from an
// idle draft, and Enter then runs the highlighted `/status`. What sits in the
// block just above the composer (past its blank top padding row):
// command list (`/`) `› /status show current session ...`
// ` /statusline configure which items ...`
// skill and mention lists ` enter insert · esc close` (key-hint row)
// (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter`
// file search (`@`) ` loading...` / ` no matches`, else bare path
// rows, which no pattern can tell from prose
// The last case is why stage also refuses the triggers themselves (below).
composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`,
// Same source. A draft starting with `/` is a slash command (the popup opens while
// the caret is in its name, and Enter runs a command either way), and one starting
// with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups
// open for the word under the caret only: `@` (file search, or the unified mention
// list) and `$` (skills and mentions; whether a given `$word` opens it depends on
// codex's skill catalog, so every `$` word is refused).
composerCommandPrefixes: '/!',
composerCompletionSigils: '@$',
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
@@ -757,7 +793,7 @@ const CODEX: CliEntry = {
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read.
modelDetect: {
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2,
},
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
+33
View File
@@ -432,6 +432,39 @@ export interface CliCapabilities {
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
/**
* Source of a regex every non-blank row BELOW an idle composer must match (at most
* three rows): the footer and hint rows this CLI draws there and nowhere else. It is
* what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove
* that the `promptGlyph` row they read is this CLI's own composer, at rest, and not
* a dialog option or a transcript line the agent printed. A CLI that omits it never
* takes guarded input: stage and submit refuse it. Tested one row at a time.
*/
composerFooterLine?: string;
/**
* Source of a regex matching a row of a suggestion popup this CLI draws directly
* ABOVE its composer (a slash-command list, a file or mention search, its key-hint
* row). The footer below such a composer is unchanged, so without this guarded
* stage/submit would read the popup frame as an idle composer, and Enter would
* accept the highlighted entry instead of sending the draft. Tested one row at a
* time against the contiguous block just above the composer row, never further
* up the pane: a match there refuses the frame. See `readGuardedComposer()`.
*/
composerPopupLine?: string;
/**
* Characters that, as the first character of a draft, make Enter run a command
* rather than send a prompt (codex: `/` slash commands, `!` shell commands).
* Guarded stage refuses such a message before typing; commands go through the
* ordinary input route.
*/
composerCommandPrefixes?: string;
/**
* Characters that, starting the word under the caret, open a completion popup
* whose highlighted entry Enter accepts (codex: `@` file and mention search, `$`
* skills). Guarded stage refuses a message whose LAST word starts with one,
* before typing: the caret ends there, and the popup follows the caret.
*/
composerCompletionSigils?: string;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its