mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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.
This commit is contained in:
1 parent
c78635a3d3
commit
2832a037d6
27 files changed
+849
-74
No files matched your search
+51
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+20
-3
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user