From 2832a037d6884f83767a4cb49d6c86f757403a2d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:22:35 -0400 Subject: [PATCH] fix(agent): verify staged text on a live codex pane and refuse popups Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and issues a receipt only for a frame with the same generation, the input revision its own write moved, and a composer hash equal to the message's. Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed. Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame capture reads the screen and the caret in one tmux invocation, with the old two calls as the fallback. Codex draws its suggestion popups above the composer and keeps the footer, so a staged /stat verified and submit ran /status. Stage now refuses a leading / or ! and a last word starting with @ or $ before typing, and stage and submit refuse a frame with popup rows just above the composer. The pattern and characters are registry data (composerPopupLine, composerCommandPrefixes, composerCompletionSigils), read from the codex 0.162.0 bottom_pane sources. Submit waits until 250 ms after the staged keys: codex turns an Enter within 120 ms of a typed burst into a newline. POST .../input answers 409 with its seq returned while a guarded submit holds the input lease, instead of 200 for bytes it never wrote. Also align the attribution memo comment with the code (a verified file whose incremental scan fails is not re-walked, since the full walk reads a superset), and list stage as a fromUser source in CLAUDE.md. --- .changeset/bounded-agent-coordination.md | 2 + CLAUDE.md | 4 +- docs/api-reference.md | 56 +++++- docs/architecture-invariants.md | 32 ++- docs/cli-registry.md | 23 ++- docs/wiki/Driving-Codeman-From-An-Agent.md | 8 +- .../reference/inspection-and-staging.md | 33 ++- .../codeman/skills/codeman/reference/verbs.md | 4 +- .../reference/inspection-and-staging.md | 33 ++- skills/codeman/reference/verbs.md | 4 +- src/agent-codex-identity.ts | 13 +- src/agent-staging.ts | 169 ++++++++++++++-- src/cli-agent.ts | 2 +- src/config/cli-registry/schema.ts | 19 ++ src/config/cli-registry/stock.ts | 22 ++ src/config/cli-registry/types.ts | 24 +++ src/session.ts | 8 + src/tmux-manager.ts | 44 +++- src/web/routes/agent-session-routes.ts | 11 +- src/web/routes/session-routes.ts | 18 ++ test/agent-codex-identity.test.ts | 16 ++ test/agent-staging-session.test.ts | 73 ++++++- test/agent-staging.test.ts | 189 +++++++++++++++++- test/cli-registry-schema.test.ts | 42 ++++ test/routes/agent-session-routes.test.ts | 11 + test/routes/session-routes.test.ts | 26 +++ test/tmux-capture-full-history.test.ts | 37 +++- 27 files changed, 849 insertions(+), 74 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 73f9513a..2ff2c0af 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -10,6 +10,8 @@ Codex rollout attribution remembers the attributed file per session and re-verif The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. +Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. While a guarded submit is in flight, `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned instead of a `200` for input that was never written. + Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/CLAUDE.md b/CLAUDE.md index a5e8101f..6cf35451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) @@ -189,7 +189,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` -**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) +**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift. diff --git a/docs/api-reference.md b/docs/api-reference.md index 2d4c6161..d99d4ef2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -426,6 +426,11 @@ worker's prompt but never submitted, and the wait then runs its full timeout on turn that never started. Verified live; this is the most common silent failure on this endpoint. +Any input, waiting or not, answers `409 CONFLICT` with nothing written while an +experimental guarded submit holds the session's input lease (a few milliseconds; +see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +Retry it. + A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as a separate key, and the server re-presses Enter while the prompt is still visibly @@ -630,8 +635,11 @@ final identity verification). The negative filter reads 1 KiB of each rollout first and 4 KiB only when that window holds no complete `originator` field. Once a rollout is attributed, later calls re-verify only that file (same file identity, same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not -older than its own, for a second candidate such as a codex `/new`; a failed check -falls back to one full walk. So the caps bound the first call, not every call. +older than its own, for a second candidate such as a codex `/new`. A file that no +longer verifies falls back to one full walk; a verified file whose incremental scan +finds a second candidate or runs out of budget answers unattributed without one, +since the full walk reads a superset of that scan. So the caps bound the first call, +not every call. An unexpected read error is `422 OPERATION_FAILED` (logged with the session id and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain @@ -670,8 +678,43 @@ actual captured pane width and rendered composer indentation, refusing any message that would reach the right edge or wrap. Every declared Codex effort word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), -never persisted prompt content. A receipt acknowledges typing; a delayed screen -paint can still make submit refuse until exact verification becomes possible. +never persisted prompt content. + +Stage refuses, before typing anything, a message that would change what Enter +does rather than what it sends, from the registry's +`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message +starting with `/` (a slash command; codex runs it, or with its command list open +runs the highlighted one) or `!` (a shell command), checked after any leading +whitespace, and a message whose last word starts with `@` (file and mention +search) or `$` (skills; every `$` word, since whether codex opens that list +depends on its skill catalog). Those popups follow the caret, which stage leaves +at the end of the message, so a `@` or `$` word earlier in the message is +accepted. Send slash and shell commands through `POST .../input` +(`codeman agent send`). Independently, a frame with a suggestion popup in the +block directly above the composer (the registry's `composerPopupLine`) is refused +at stage and submit, since codex draws every popup there and leaves the footer +below the composer unchanged. + +A receipt is issued only for a capture that shows exactly the message with the +caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive, +so after typing stage re-reads the pane every 50 ms for up to one second and +takes the first frame with the same pane generation, the input revision its own +write moved and a composer whose SHA-256 equals the message's. Each capture +reads the screen and the caret in one tmux invocation. A frame showing another +writer's input, a new pane lifecycle or a new geometry ends the wait at once. +Every refusal after the keys went out (typing failed or was ambiguous, the +pane changed, or no frame verified in time) is a `409` that says the message +may already be in the composer: inspect the pane and follow the stranded-draft +recovery (Ctrl+U) in the skill reference before staging again. + +While a guarded submit holds the input lease (the few milliseconds of its +conditional Enter), every other Codeman write path refuses, and +`POST /api/v1/sessions/:id/input` answers `409 CONFLICT` ("A guarded submit is in +progress on this session; nothing was written") instead of a `200` for bytes +that were never written. A tagged `clientId`/`seq` frame keeps its seq, so the +browser's durable queue, which keeps a frame on a `409`, delivers it on the next +retry. WebSocket keystrokes in that window are left unacknowledged and redelivered +the same way. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve @@ -679,7 +722,10 @@ input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, -confirmation hash, expiry, lifecycle/input/geometry revisions and exact composer/caret, +confirmation hash and expiry, then waits until at least 250 ms have passed since +the staged keys went out (codex reads keys typed in one burst as a paste and, for +120 ms after one, turns Enter into a newline instead of a submit), then verifies +lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index edd12634..d0fd2444 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. -**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. **The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. @@ -1290,8 +1290,11 @@ metadata are remembered per session (`attributionMemo`, bounded LRU). A later ca re-verifies only that file's head, then scans just the date directories not older than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is never pruned) for a second candidate, which is how a codex `/new` or a new owned -rollout beside a configured resume is still caught. A failed check drops the memo -and falls back to one full walk. Non-`InspectionConflict` errors are `422 +rollout beside a configured resume is still caught. A head that no longer verifies +drops the memo and falls back to one full walk; a verified head whose incremental +scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it +and answers unattributed without one, because the full walk reads a superset of that +scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422 OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and error code only, never the path. @@ -1301,8 +1304,25 @@ footer are registry data (`capabilities.workDetect.promptGlyph` and declaring `composerFooterLine` takes guarded input (codex today). Codex's footer words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, -including indentation; reaching the last column or wrapping is refused. Stage -cancels older verifier retries and sends no Enter. Receipts contain hashes and +including indentation; reaching the last column or wrapping is refused. A message +starting with a `composerCommandPrefixes` character or whose last word starts with a +`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a +frame with a `composerPopupLine` row in the block just above the composer is refused at +stage and submit: codex draws its popups there and keeps its footer, so the footer +alone read `› /stat` with the command list open as idle, and Enter ran `/status`. +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` +50) for the first frame with the same generation, `revision === before + 1` and +`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys +and the first capture is often from before them; another writer or a lifecycle change +ends the wait at once, and every refusal after typing says the message may be in the +composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call +(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to +the two calls on any doubt; keep the poll anyway. While a guarded submit holds the +lease, `POST .../input` answers `409` (seq returned) instead of a `200` for unwritten +bytes. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and +an Enter within 120 ms of one becomes a newline while submit would report it +delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and bounded ephemeral identities only, expire, and are consumed before awaited submission, including failures. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 955b6f88..a2a029e8 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,10 +47,12 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines?, composerFooterLine? } + // blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?, + // composerCommandPrefixes?, composerCompletionSigils? } // (how this CLI's pane shows work, work it started in the background, a turn // that ended waiting for workers it will resume from, a dialog it is blocked on, - // and the rows under its idle composer that guarded input relies on) + // the rows around its idle composer that guarded input relies on, and what a + // draft can trigger) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -64,7 +66,7 @@ interface CliEntry { ### Regexes that come from config -Seven capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine` and `capabilities.modelDetect.screenLine`. All seven go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -137,6 +139,21 @@ effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the foot of the screen, so a pattern must anchor on chrome only that CLI draws there. +Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup +(its slash-command list, file search, skill and mention lists) ABOVE the composer and +leaves the footer below it alone, so the footer check reads `› /stat` with the command +list open as an idle draft, and Enter would run the highlighted `/status`. +`composerPopupLine` matches a popup row; it is tested against the contiguous block of +non-blank rows directly above the composer (past at most three blank rows, at most twelve +rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists +the characters that, first in a draft, make Enter run a command (codex: `/!`), and +`composerCompletionSigils` the ones that, starting the word under the caret, open a popup +(codex: `@$`); stage refuses such a message before typing, the sigils only on the last +word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's +values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search +popup with results shows bare path rows no pattern can tell from prose, which is why the +trigger refusal is there as well as the frame check. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 18d21b5d..84c57644 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -75,9 +75,11 @@ codeman agent rm "$SID" # any session except t [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by default), and they may change in any release. `inspect` returns bounded evidence with an opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a - local Codex composer, with no Enter, and returns a receipt that expires after two minutes; - `submit` sends one Enter only if the composer still shows exactly that text, and never - replays it. `send --no-enter` alone has no receipt. Details: + local Codex composer, with no Enter, and returns a receipt that expires after two minutes + once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands + and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter + would accept), so send commands with `send`. `submit` sends one Enter only if the + composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details: [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **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`. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index c53530aa..960a1f13 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -78,15 +78,32 @@ wrapped, remote/container and shell composers are refused. A customized footer o unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + The receipt stores a hash and ephemeral identities, never prompt content. It -expires after two minutes; capacity is 1024 live receipts. Stage delivery can -precede the next screen paint: a receipt acknowledges typing, while submit still -requires the fully painted exact composer. If typing fails or is ambiguous, stage -issues no receipt; inspect the pane before trying again. +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds), `send` and other input to that +session answer `409 CONFLICT` with nothing written; retry the input. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input -boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. @@ -98,10 +115,10 @@ Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. -## Recover after a refused submit +## Recover a stranded draft -A consumed receipt cannot be reused. First observe whether the prompt was -accepted: use transcript evidence/wait signals and a fresh terminal capture +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture (`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; do not clear or resend anything. A transcript prompt row alone is not proof diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 85a7b981..3982b305 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -551,8 +551,8 @@ that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified - draft merely to clear it. For a refused guarded local Codex submit, use the - verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index c53530aa..960a1f13 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -78,15 +78,32 @@ wrapped, remote/container and shell composers are refused. A customized footer o unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + The receipt stores a hash and ephemeral identities, never prompt content. It -expires after two minutes; capacity is 1024 live receipts. Stage delivery can -precede the next screen paint: a receipt acknowledges typing, while submit still -requires the fully painted exact composer. If typing fails or is ambiguous, stage -issues no receipt; inspect the pane before trying again. +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds), `send` and other input to that +session answer `409 CONFLICT` with nothing written; retry the input. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input -boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. @@ -98,10 +115,10 @@ Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. -## Recover after a refused submit +## Recover a stranded draft -A consumed receipt cannot be reused. First observe whether the prompt was -accepted: use transcript evidence/wait signals and a fresh terminal capture +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture (`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; do not clear or resend anything. A transcript prompt row alone is not proof diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 85a7b981..3982b305 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -551,8 +551,8 @@ that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified - draft merely to clear it. For a refused guarded local Codex submit, use the - verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 490f7267..1ff2f280 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -77,8 +77,10 @@ function strictMetadata(bytes: Buffer): Metadata | undefined { * this file's head (same `dev:ino`, same strict metadata) and then scans just the * directories not older than its own for a second candidate (codex `/new`, or a new * owned rollout beside a configured resume), instead of re-reading the whole store, - * which codex never prunes. Any failed check drops the entry and falls back to the - * full walk. Bounded: one entry per live session is the expected size. + * which codex never prunes. A head that no longer verifies drops the entry and falls + * back to the full walk; a failed incremental scan drops it and answers unattributed, + * since the full walk reads a superset of that scan. Bounded: one entry per live + * session is the expected size. */ interface AttributionMemo { id: string; @@ -133,8 +135,11 @@ export async function findExactCodexConversation( } } attributionMemo.delete(key); - // A second candidate is a conflict a full walk would report too; anything - // else (a replaced file, an exhausted budget) gets one full walk now. + // A file that no longer verifies (replaced, rewritten, gone) gets one full walk + // now. A verified file whose incremental scan failed does not: that scan reads a + // subset of what a full walk reads under the same budget, so a second candidate, + // an exhausted budget or an unreadable entry it hit is one the full walk would hit + // too, and walking the whole never-pruned store could only end the same way. if (verified) return undefined; } const candidates = await scanCandidates(sessions, options, budget); diff --git a/src/agent-staging.ts b/src/agent-staging.ts index 84795170..f79e1234 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -10,14 +10,21 @@ import { compileVersionRegex } from './config/cli-registry/patterns.js'; import type { CliEntry } from './config/cli-registry/types.js'; /** - * The screen chrome a guarded composer read relies on, both from the CLI registry - * (`capabilities.workDetect`): the composer glyph and the pattern every row below an - * idle composer must match. Nothing here names a CLI; a CLI takes part in guarded - * input by declaring `composerFooterLine`. + * The screen chrome a guarded composer read relies on, all from the CLI registry + * (`capabilities.workDetect`): the composer glyph, the pattern every row below an + * idle composer must match, and what opens a popup or turns Enter into a command. + * Nothing here names a CLI; a CLI takes part in guarded input by declaring + * `composerFooterLine`. */ export interface GuardedComposerChrome { promptGlyph: string; footerLine: RegExp; + /** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */ + popupLine?: RegExp; + /** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */ + commandPrefixes?: string; + /** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */ + completionSigils?: string; } /** The chrome a registry entry declares, or null (no guarded input for that CLI). */ export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { @@ -25,8 +32,42 @@ export function guardedComposerChrome(entry: CliEntry | undefined): GuardedCompo if (!detect?.promptGlyph || !detect.composerFooterLine) return null; // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. const footerLine = compileVersionRegex(detect.composerFooterLine); - return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null; + if (!footerLine) return null; + // A declared popup pattern the guard refuses is no chrome at all: dropping it would + // silently read a popup frame as an idle composer. + const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined; + if (popupLine === null) return null; + return { + promptGlyph: detect.promptGlyph, + footerLine, + ...(popupLine ? { popupLine } : {}), + ...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}), + ...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}), + }; } +/** + * Why a message cannot be staged into this composer, or null. Typing it would change + * what Enter does rather than what it sends: a draft starting with a command prefix + * (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a + * prompt, and a last word starting with a completion sigil (codex: `@` file and + * mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter + * would accept. Checked before anything is typed. Only the LAST word matters for the + * sigils because the caret is at the end of the message and the popup follows the + * caret; a sigil word earlier in the message opens nothing. + */ +export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null { + const first = message.trimStart().charAt(0); + if (first && chrome.commandPrefixes?.includes(first)) + return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`; + const last = message.split(/\s/).at(-1)?.charAt(0); + if (last && chrome.completionSigils?.includes(last)) + return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``; + return null; +} +/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */ +const POPUP_WINDOW_ROWS = 12; +/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */ +const POPUP_GAP_ROWS = 3; const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const composerRow = (chrome: GuardedComposerChrome) => new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); @@ -41,6 +82,8 @@ export interface GuardedInputSnapshot { } export interface GuardedInputTarget { id: string; + /** The session's composer chrome, read before typing for what a message would trigger. */ + chrome(): GuardedComposerChrome | null; snapshot(): GuardedInputSnapshot | null; write(text: string): Promise<boolean>; submit(expected: GuardedInputSnapshot): Promise<boolean>; @@ -50,9 +93,46 @@ interface Receipt extends GuardedInputSnapshot { sessionId: string; expiresAt: number; sha256: string; + /** When the staged keys went out, for `SUBMIT_QUIET_MS`. */ + typedAt: number; } export class GuardedInputConflict extends Error {} +/** + * How long stage keeps reading the pane after typing for a frame that shows the + * message, and how often. A capture taken right after the keys land is often a + * frame from before them: codex repaints its composer 30 to 60 ms after input + * arrives (measured on a live 0.162.0 pane), so the first read can show the old, + * empty composer, or nothing provable at all. A second of 50 ms reads covers that + * many times over and still answers the caller quickly. + */ +export const STAGE_SETTLE_TIMEOUT_MS = 1000; +export const STAGE_SETTLE_INTERVAL_MS = 50; +/** + * The least time between the staged keys and the conditional Enter. Stage types its + * text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a + * paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather + * than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/ + * paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers + * would land inside it and be told one Enter was delivered while codex only broke + * the line. Submit waits out the rest of this window first; a keystroke in that + * time still invalidates the receipt. + */ +export const SUBMIT_QUIET_MS = 250; +/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */ +export interface StageSettleOptions { + timeoutMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise<void>; +} +/** + * Every refusal after the keys went out says so: stage cannot tell whether the text + * landed, so the caller must look before staging again (the recovery steps clear a + * stranded draft with Ctrl+U). + */ +export const MAY_BE_TYPED = + 'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again'; + /** * Conservatively read a composer: exactly one visible, unwrapped row and a known * footer, both as the registry describes them. Never remove internal spaces, infer @@ -69,6 +149,7 @@ export function readGuardedComposer(screen: string, chrome: GuardedComposerChrom if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; + if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -94,6 +175,23 @@ export function readGuardedComposer(screen: string, chrome: GuardedComposerChrom .trimEnd(); } +/** + * Does a suggestion popup sit directly above the composer row? A popup draws over + * the rows above the composer and leaves the footer below it alone, so the footer + * check alone reads a popup frame as an idle composer. Read the contiguous block of + * rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane: + * the transcript further up is the agent's own and may print anything. A transcript + * row that happens to match costs a refusal, never a receipt. + */ +function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean { + let index = composerIndex - 1; + for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--; + for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) { + if (popupLine.test(lines[index])) return true; + } + return false; +} + /** Require the capture's caret at the exact end of the unwrapped composer. */ export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; @@ -134,11 +232,19 @@ export function readGuardedSnapshotDetails( export class GuardedStageStore { private readonly receipts = new Map<string, Receipt>(); private readonly busy = new Set<string>(); + private readonly settleTimeoutMs: number; + private readonly settleIntervalMs: number; + private readonly sleep: (ms: number) => Promise<void>; constructor( private readonly now = Date.now, private readonly capacity = 1024, - private readonly ttlMs = 120000 - ) {} + private readonly ttlMs = 120000, + settle: StageSettleOptions = {} + ) { + this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS; + this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS; + this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } async stage(target: GuardedInputTarget, owner: string, message: string) { // eslint-disable-next-line no-control-regex const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message); @@ -152,6 +258,9 @@ export class GuardedStageStore { throw new GuardedInputConflict( 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace' ); + const chrome = target.chrome(); + const refusal = chrome && composerTriggerRefusal(message, chrome); + if (refusal) throw new GuardedInputConflict(refusal); const key = target.id; if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress'); this.busy.add(key); @@ -169,24 +278,48 @@ export class GuardedStageStore { if (this.receipts.size + this.busy.size > this.capacity) throw new GuardedInputConflict('Guarded stage receipt capacity reached'); if (!(await target.write(message))) - throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); - const after = target.snapshot(); - if ( - !after || - after.generation !== before.generation || - after.revision !== before.revision + 1 || - after.availableColumns !== before.availableColumns - ) - throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); + throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`); + const typedAt = this.now(); + const after = await this.settle(target, before, promptSha256(message)); const stageId = randomUUID(); const expiresAt = this.now() + this.ttlMs; const sha256 = promptSha256(message); - this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256 }); + this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt }); return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const }; } finally { this.busy.delete(key); } } + /** + * The first frame after the write that shows exactly this message, with the input + * revision the write itself moved and nothing else changed. A null or stale frame + * (the old composer, a half-painted one) is read again until the settle window + * ends; a frame showing another writer's keystroke, a new pane lifecycle or a new + * geometry is refused at once, since waiting cannot undo that. + */ + private async settle( + target: GuardedInputTarget, + before: GuardedInputSnapshot, + expectedSha: string + ): Promise<GuardedInputSnapshot> { + const deadline = this.now() + this.settleTimeoutMs; + for (;;) { + const frame = target.snapshot(); + if ( + frame && + (frame.generation !== before.generation || + frame.revision !== before.revision + 1 || + frame.availableColumns !== before.availableColumns) + ) + throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`); + if (frame?.composerSha === expectedSha) return frame; + if (this.now() >= deadline) + throw new GuardedInputConflict( + `The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}` + ); + await this.sleep(this.settleIntervalMs); + } + } async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) { const receipt = this.receipts.get(stageId); if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id) @@ -198,6 +331,8 @@ export class GuardedStageStore { try { if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha) throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch'); + const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now(); + if (quiet > 0) await this.sleep(quiet); const current = target.snapshot(); if ( !current || diff --git a/src/cli-agent.ts b/src/cli-agent.ts index b8e09d51..082c3ee8 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -1293,7 +1293,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage <id>') .description( - 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)' ) .requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 3876310b..8c6486f5 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -394,6 +394,25 @@ const capabilitiesSchema = z '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 diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 59bf4951..187d7359 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -748,6 +748,28 @@ const CODEX: CliEntry = { // `? 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, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 0f5a97f1..fc873070 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -441,6 +441,30 @@ export interface CliCapabilities { * 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 diff --git a/src/session.ts b/src/session.ts index 3b965eb8..1b1410d1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4749,6 +4749,14 @@ export class Session extends EventEmitter { get guardedInputPendingWrites(): number { return this._guardedPendingWrites; } + /** + * True while a guarded submit holds the input lease. Every write path refuses for + * that span (a few milliseconds), so a route that can report a refusal checks this + * first instead of answering as if the bytes went out. + */ + get guardedSubmitInFlight(): boolean { + return this._guardedSubmitInFlight; + } /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ getCodexHomeOverride(): string | undefined { return this._envOverrides?.CODEX_HOME || undefined; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0c52cf39..0eba7b22 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -984,6 +984,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string { return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`; } +/** + * Split the output of `capture-pane -p \; display-message -p '<cursor format>'` + * into the captured rows and the cursor line, which tmux prints last. Null unless + * that last line has the cursor query's shape (four leading integers), so a query + * that printed nothing can never have the frame's own last row taken for it. + */ +export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null { + const body = output.endsWith('\n') ? output.slice(0, -1) : output; + const split = body.lastIndexOf('\n'); + const cursor = body.slice(split + 1); + if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null; + return { frame: body.slice(0, split + 1), cursor }; +} + export function formatPaneSnapshot( lines: string[], geometry: { cols: number; rows: number; cursorX: number; cursorY: number } @@ -4319,9 +4333,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { execOpts.maxBuffer = (opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES; } + const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`; + const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`; + // The visible frame and its caret come from ONE tmux invocation + // (the capture, `\;`, then the cursor query): tmux runs a command list back to back + // in its command queue, with no pane output parsed between the two, so the + // caret belongs to the frame. Two separate calls raced the CLI's repaint: a + // CLI that redraws its composer a few ms after input arrives (codex) could be + // captured before the redraw and its caret queried after it, and guarded + // stage then refused a frame the keys had already reached. Any doubt about + // the combined output (a failed call, a last line that is not the query's) + // falls back to the two separate calls, which is what this always did. + const combined = fullHistory ? null : this.captureFrameWithCursor(`${captureCommand} \\; ${cursorQuery}`); const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts), + combined?.frame ?? execSync(captureCommand, execOpts), scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path @@ -4329,10 +4355,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // appended, and those two have to agree: trailing blank rows are only safe // to keep when a move follows to put the caret back above them. const geometry = queryPaneCursor(() => - execSync( - `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ) + combined + ? combined.cursor + : execSync(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) ); // Report the size the pane was really drawing at. The visible-frame path // below addresses every row absolutely, so a consumer whose terminal is @@ -4398,6 +4423,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** One combined capture-and-cursor-query call, split; null when it failed or is ambiguous. */ + private captureFrameWithCursor(command: string): { frame: string; cursor: string } | null { + try { + return splitFrameAndCursor(execSync(command, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })); + } catch { + return null; + } + } + /** * Capture the active pane for a tmux session. * diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index 280138ff..01101893 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -14,7 +14,13 @@ import type { SessionPort, InfraPort } from '../ports/index.js'; import { getCli } from '../../config/cli-registry/registry.js'; import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; -import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; +import { + GuardedStageStore, + GuardedInputConflict, + guardedComposerChrome, + MAY_BE_TYPED, + type GuardedInputTarget, +} from '../../agent-staging.js'; import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; @@ -51,6 +57,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor const generation = (session: Session) => session.inspectionGeneration; const target = (session: Session): GuardedInputTarget => ({ id: session.id, + chrome: () => guardedComposerChrome(getCli(session.mode)), snapshot: () => { if (ctx.sessions.get(session.id) !== session) return null; const snapshot = session.guardedInputSnapshot?.(); @@ -153,7 +160,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor ApiErrorCode.CONFLICT, error instanceof GuardedInputConflict ? error.message - : 'Stage delivery failed; inspect the pane before retrying' + : `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}` ) ); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index fbceb684..369ea2dc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2077,6 +2077,24 @@ export function registerSessionRoutes( } } + // A guarded submit (agent-staging.ts) holds the input lease for the few + // milliseconds of its conditional Enter, and every write path refuses while it + // does. Say so instead of answering 200 for bytes that were never written, and + // give the seq back so the browser's durable queue (which keeps a frame on any + // 409) delivers it once the lease is released. Nothing from here to the write + // below awaits, so the lease cannot be taken in between. + if (!duplicate && session.guardedSubmitInFlight === true) { + if (tagged) session.forgetInputSeq(clientId as string, seq as number); + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + 'A guarded submit is in progress on this session; nothing was written. Retry the input.' + ) + ); + } + // Only a waiting request pays for the tmux probe: the browser's plain input path // (thousands of calls per session) must stay exec-free. const workerDead = wantsWait && workerIsDead(ctx.mux, session); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index a1292275..a0e57176 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -224,6 +224,22 @@ describe('remembered attribution on a large, never-pruned store', () => { expect(await findExactCodexConversation(f.options)).toBeUndefined(); }); + it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 30 rollouts in the remembered day itself: the incremental scan reads them all, + // so 20 entries cannot cover it, and a full walk (a superset) could not either. + await Promise.all( + Array.from({ length: 30 }, (_, n) => + dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' }) + ) + ); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + // The memo was dropped, so a call with room walks the store and attributes again. + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }); + it('drops the remembered file when it is replaced, and walks again', async () => { const f = await fixture(); const path = await dated(f, '2026/10/24', OWNED); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index 8d97cc23..ff106fdf 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -413,16 +413,18 @@ it('refuses a message that would wrap using the actual captured indent and width }, muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters<typeof Session>[0]); - const { GuardedStageStore } = await import('../src/agent-staging.js'); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); try { const store = new GuardedStageStore(); const target = { id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), snapshot: () => session.guardedInputSnapshot(), write: (text: string) => session.stageGuardedInput(text), submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), }; - await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(); + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/); expect(writes).toEqual([]); } finally { await session.stop(false); @@ -464,3 +466,70 @@ it('records a verifier re-press as the latest submit time', async () => { vi.useRealTimers(); } }); + +it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => { + // Reproduces the live 0.162.0 race: right after the keys land, the screen still + // shows the empty composer while the caret is already past the new text. That + // frame proves nothing; the repaint a few ms later does. + let composer = ''; + let torn = 0; + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + const shown = torn > 0 ? '' : composer; + if (torn > 0) torn--; + return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + if (text !== '\r') { + composer += text; + torn = 2; + } + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); + const sleeps: number[] = []; + const store = new GuardedStageStore(Date.now, 1024, 120000, { + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + const target = { + id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), + }; + try { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(promptSha256('hello')); + expect(sleeps).toHaveLength(2); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({ + submitted: true, + }); + expect(writes).toEqual(['hello', '\r']); + } finally { + await session.stop(false); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 976e2cbd..e6055b75 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -1,9 +1,13 @@ /** @fileoverview Guarded staging one-shot and content-verification safety. */ -import { beforeEach, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; import { GuardedStageStore, + STAGE_SETTLE_INTERVAL_MS, + STAGE_SETTLE_TIMEOUT_MS, + SUBMIT_QUIET_MS, + composerTriggerRefusal, guardedComposerChrome, readGuardedComposer, readGuardedSnapshot, @@ -23,16 +27,24 @@ let composer: string | null; let writes: string[]; let fail: boolean; let target: GuardedInputTarget; +/** Every settle-window wait, on the fake clock: no real timer runs in this file. */ +let sleeps: number[]; +const fakeSleep = async (ms: number) => { + sleeps.push(ms); + time += ms; +}; beforeEach(() => { + sleeps = []; time = 0; generation = 'pane-1'; revision = 0; composer = ''; writes = []; fail = false; - store = new GuardedStageStore(() => time, 2, 1000); + store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep }); target = { id: 's', + chrome: () => CODEX, snapshot: () => composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, submit: async () => target.write('\r'), @@ -144,7 +156,7 @@ it('requires capture cursor geometry, rejecting invisible trailing edits and mov expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello'); }); it('reserves receipt capacity before concurrent stages type', async () => { - store = new GuardedStageStore(() => time, 1, 1000); + store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep }); let release: (() => void) | undefined; const firstTarget = { ...target, @@ -195,3 +207,174 @@ it('takes the composer glyph and footer from the registry, and only codex declar unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; expect(guardedComposerChrome(unsafe)).toBeNull(); }); + +describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => { + /** Serve these post-write frames in order, then the target's own snapshot. */ + function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) { + let written = false; + const own = target.snapshot; + const write = target.write; + target.write = async (text) => { + const ok = await write(text); + written = true; + return ok; + }; + target.snapshot = () => { + if (!written || frames.length === 0) return own(); + const next = frames.shift()!; + return next ? next() : null; + }; + } + const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 }); + + it('names its bounds', () => { + expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000); + expect(STAGE_SETTLE_INTERVAL_MS).toBe(50); + }); + it('issues a receipt once a null first frame is followed by a valid one', async () => { + afterWrite([null]); + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(sha('hello')); + expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]); + }); + it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => { + afterWrite([stale, stale]); + await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false }); + expect(sleeps).toHaveLength(2); + }); + it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => { + target.snapshot = () => (writes.length ? null : stale()); + const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message); + expect(refusal).toMatch(/within 1000 ms; no receipt was issued/); + expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/); + expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS); + }); + it('issues no receipt for a frame whose composer text differs from the message', async () => { + const write = target.write; + target.write = async (text) => { + await write(text); + composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key + return true; + }; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/); + // Nothing to submit: no receipt was stored for the frame that did not match. + composer = 'hello'; + await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow( + /Unknown/ + ); + }); + it('refuses at once, without waiting, when another writer moved the input during the window', async () => { + afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]); + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/ + ); + expect(sleeps).toEqual([]); + }); + it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/ + ); + }); +}); + +describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => { + const footer = ' gpt-5.6-terra default · ~/case'; + /** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */ + const frame = (popup: string[], draft: string) => + ['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n'); + + it('reads the idle frame with transcript above it', () => { + expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello'); + }); + it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => { + const popup = [ + '› /status show current session configuration and token usage', + ' /statusline configure which items appear in the status line', + ]; + expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull(); + // A wrapped description row at the bottom of the list does not hide it. + expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull(); + }); + it('refuses the skill, mention and file-search popups', () => { + const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close']; + const mention = [ + ' Mentions', + ' All Results Filesystem Only Plugins', + ' sa', + '› Sample Plugin Plugin with skills and an MCP server Plugin', + ' enter/tab insert · esc close · ↑/↓ select · ←/→ filter', + ]; + expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull(); + expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull(); + expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull(); + expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull(); + }); + it('looks only at the block directly above the composer, not the transcript further up', () => { + const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n'); + expect(readGuardedCodexComposer(screen)).toBe('hello'); + }); + it('takes the popup pattern and the trigger characters from the registry', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source); + expect(CODEX.commandPrefixes).toBe('/!'); + expect(CODEX.completionSigils).toBe('@$'); + // A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); + }); + it.each([ + ['/stat', '/'], + ['/status now', '/'], + [' /review', '/'], + ['!ls -la', '!'], + ['review @src/app', '@'], + ['@notes', '@'], + ['run $skill', '$'], + ['costs $5', '$'], + ])('refuses %j before typing (trigger %s)', async (message, trigger) => { + expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`); + await expect(store.stage(target, 'alice', message)).rejects.toThrow( + trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/ + ); + expect(writes).toEqual([]); + }); + it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])( + 'stages %j: no trigger at the start, and the caret word opens nothing', + async (message) => { + expect(composerTriggerRefusal(message, CODEX)).toBeNull(); + await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false }); + } + ); +}); + +describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => { + it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => { + expect(SUBMIT_QUIET_MS).toBeGreaterThan(120); + const receipt = await store.stage(target, 'alice', 'hello'); + time += 40; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]); + expect(writes).toEqual(['hello', '\r']); + }); + it('does not wait once the window has passed', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + time += SUBMIT_QUIET_MS; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([]); + }); + it('still refuses when a keystroke lands during the wait', async () => { + store = new GuardedStageStore(() => time, 2, 1000, { + sleep: async (ms) => { + await fakeSleep(ms); + revision++; // the user typed while submit waited + }, + }); + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow( + /changed; nothing submitted/ + ); + expect(writes).toEqual(['hello']); + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 5c4a468c..5ee368ee 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -205,6 +205,48 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => { + // Run over the rows just above the composer each time guarded input reads the pane. + for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerPopupLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerPopupLine; + if (src) + expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull(); + } + }); + + it('takes only short runs of ASCII punctuation as composer trigger characters', () => { + for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) { + for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) { + expectRejected( + (e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: value, + }; + }, + `${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set` + ); + } + const entry = baseEntry(); + (entry.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: '/!@$', + }; + expect(CliEntrySchema.safeParse(entry).success, field).toBe(true); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 8bafbb15..e97e401b 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -250,6 +250,17 @@ it('uses real session writes to stage without Enter and refuses intervening API }); expect(stale.statusCode).toBe(409); expect(writes.filter((s) => s === '\r')).toHaveLength(1); + // Codex's command popup opens on `/`; Enter would run the highlighted command. + composer = ''; + writes.length = 0; + const slash = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: '/stat' }, + }); + expect(slash.statusCode).toBe(409); + expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/); + expect(writes).toEqual([]); } finally { await session.stop(false); } diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b531756d..f770a76c 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1197,6 +1197,32 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual(['prompt', '\r']); }); + it('refuses with 409 and gives the seq back while a guarded submit holds the input lease', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedSubmitInFlight = true; + for (const payload of [ + { input: 'x' }, + { input: 'x', useMux: true }, + { input: 'prompt\r' }, + { input: 'tagged', seq: 7, clientId: 'cid-guard' }, + ]) { + const refused = await post(payload); + expect(refused.statusCode, JSON.stringify(payload)).toBe(409); + expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + } + expect(session.writeBuffer).toEqual([]); + // Once the lease is released the browser's retry of the same seq is applied, not + // rejected as a duplicate. + session.guardedSubmitInFlight = false; + expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['tagged']); + }); + it('always applies untagged input (curl/legacy, no dedup)', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 9ed86ea3..b9b721f6 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -11,7 +11,13 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js'; +import { + formatCursorRestore, + formatPaneSnapshot, + hasVisibleContent, + queryPaneCursor, + splitFrameAndCursor, +} from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); @@ -265,3 +271,32 @@ describe('queryPaneCursor history depth', () => { expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"); }); }); + +describe('the visible frame and its caret come from one tmux invocation', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); + const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); + const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart)); + + it('chains the capture and the cursor query with `\;` on the visible path only', () => { + // Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms + // after input): the frame could predate the caret, and guarded stage refused it. + // Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`. + const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}'; + expect(methodBody).toContain('fullHistory ? null : this.captureFrameWithCursor(`' + chained + '`)'); + // Any doubt falls back to the two calls the capture always made. + expect(methodBody).toContain('combined?.frame ?? execSync(captureCommand, execOpts)'); + expect(methodBody).toContain('combined\n ? combined.cursor'); + }); + it('splits the combined output into the rows and the cursor line tmux prints last', () => { + expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({ + frame: '› hello\n footer\n\n', + cursor: '7 0 100 20 0 0', + }); + expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' }); + }); + it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => { + expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull(); + expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull(); + expect(splitFrameAndCursor('')).toBeNull(); + }); +});