2026-10-23 14:15:14 -04:00
/**
* @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is
* retained: only caller/session identity, lifecycle/input revisions and SHA-256.
* A receipt is consumed synchronously before verification/delivery, including failures.
*/
import { createHash , randomUUID } from 'node:crypto' ;
import { stripAnsi } from './utils/regex-patterns.js' ;
import { visibleWidth } from './tui/tui-ansi.js' ;
2026-10-24 22:54:17 -04:00
import { compileVersionRegex } from './config/cli-registry/patterns.js' ;
import type { CliEntry } from './config/cli-registry/types.js' ;
/**
2026-10-25 09:22:35 -04:00
* 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`.
2026-10-24 22:54:17 -04:00
*/
export interface GuardedComposerChrome {
promptGlyph : string ;
footerLine : RegExp ;
2026-10-25 09:22:35 -04:00
/** 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 ;
2026-10-24 22:54:17 -04:00
}
/** The chrome a registry entry declares, or null (no guarded input for that CLI). */
export function guardedComposerChrome ( entry : CliEntry | undefined ) : GuardedComposerChrome | null {
const detect = entry ? . capabilities . workDetect ;
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 );
2026-10-25 09:22:35 -04:00
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 } : {}),
};
2026-10-24 22:54:17 -04:00
}
2026-10-25 09:22:35 -04:00
/**
* 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 ;
2026-10-24 22:54:17 -04:00
const escapeRegex = ( text : string ) => text . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' );
const composerRow = ( chrome : GuardedComposerChrome ) =>
new RegExp ( String . raw `^ \ s* ${ escapeRegex ( chrome . promptGlyph ) } (?: \ s|$)` );
2026-10-23 14:15:14 -04:00
export const STAGE_MAX_BYTES = 8192 ;
export const promptSha256 = ( text : string ) : string => createHash ( 'sha256' ). update ( text ). digest ( 'hex' );
export interface GuardedInputSnapshot {
generation : string ;
revision : number ;
composerSha : string ;
2026-10-23 16:22:46 -04:00
/** Actual captured pane width minus the rendered composer prefix (including indentation). */
availableColumns : number ;
2026-10-23 14:15:14 -04:00
}
export interface GuardedInputTarget {
id : string ;
2026-10-25 09:22:35 -04:00
/** The session's composer chrome, read before typing for what a message would trigger. */
chrome () : GuardedComposerChrome | null ;
2026-10-23 14:15:14 -04:00
snapshot () : GuardedInputSnapshot | null ;
write ( text : string ) : Promise < boolean >;
submit ( expected : GuardedInputSnapshot ) : Promise < boolean >;
}
interface Receipt extends GuardedInputSnapshot {
owner : string ;
sessionId : string ;
expiresAt : number ;
sha256 : string ;
2026-10-25 09:22:35 -04:00
/** When the staged keys went out, for `SUBMIT_QUIET_MS`. */
typedAt : number ;
2026-10-23 14:15:14 -04:00
}
export class GuardedInputConflict extends Error {}
2026-10-25 09:22:35 -04:00
/**
* 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' ;
2026-10-23 14:15:14 -04:00
/**
2026-10-24 22:54:17 -04:00
* Conservatively read a composer: exactly one visible, unwrapped row and a known
* footer, both as the registry describes them. Never remove internal spaces, infer
* an unknown shell prompt, or interpret dialog options as authority. An unsupported
* frame yields no proof.
2026-10-23 14:15:14 -04:00
*/
2026-10-24 22:54:17 -04:00
export function readGuardedComposer ( screen : string , chrome : GuardedComposerChrome ) : string | null {
const isComposer = composerRow ( chrome );
2026-10-23 14:15:14 -04:00
const lines = stripAnsi ( screen )
. split ( '\n' )
. map (( line ) => line . trimEnd ());
2026-10-24 22:54:17 -04:00
const rows = lines . map (( line , index ) => ({ line , index })). filter (( row ) => isComposer . test ( row . line ));
2026-10-23 14:15:14 -04:00
const last = rows . at ( - 1 );
if ( ! last ) return null ;
const below = lines . slice ( last . index + 1 ). filter (( line ) => line . trim ());
2026-10-24 22:54:17 -04:00
if ( below . length === 0 || below . length > 3 || below . some (( line ) => ! chrome . footerLine . test ( line ))) return null ;
2026-10-25 09:22:35 -04:00
if ( chrome . popupLine && popupAbove ( lines , last . index , chrome . popupLine )) return null ;
2026-10-23 14:15:14 -04:00
// Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded.
const raw = screen . split ( '\n' )[ last . index ];
let dim = false ;
let visible = '' ;
// eslint-disable-next-line no-control-regex
for ( const token of raw . split ( /(\x1b\[[0-9;]*m)/ )) {
// eslint-disable-next-line no-control-regex
if ( /^\x1b\[/ . test ( token )) {
const codes = token . slice ( 2 , - 1 ). split ( ';' ). map ( Number );
for ( let i = 0 ; i < codes . length ; i ++ ) {
// Extended color payloads may contain 0, 2 or 22; those are not SGR modes.
if ( codes [ i ] === 38 || codes [ i ] === 48 || codes [ i ] === 58 ) {
i += codes [ i + 1 ] === 2 ? 4 : codes [ i + 1 ] === 5 ? 2 : 0 ;
} else if ( codes [ i ] === 0 || codes [ i ] === 22 ) dim = false ;
else if ( codes [ i ] === 2 ) dim = true ;
}
} else if ( ! dim ) visible += token ;
}
2026-10-24 22:54:17 -04:00
// The glyph and the one space after it are the prefix, not input.
return stripAnsi ( visible )
. trimStart ()
. slice ( chrome . promptGlyph . length + 1 )
. trimEnd ();
2026-10-23 14:15:14 -04:00
}
2026-10-25 09:22:35 -04:00
/**
* 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 ;
}
2026-10-23 14:15:14 -04:00
/** Require the capture's caret at the exact end of the unwrapped composer. */
2026-10-24 22:54:17 -04:00
export function readGuardedSnapshot ( snapshot : string , chrome : GuardedComposerChrome ) : string | null {
return readGuardedSnapshotDetails ( snapshot , chrome ) ? . composer ?? null ;
2026-10-23 16:22:46 -04:00
}
/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
2026-10-24 22:54:17 -04:00
export function readGuardedSnapshotDetails (
snapshot : string ,
chrome : GuardedComposerChrome
) : { composer : string ; prefixWidth : number } | null {
2026-10-23 14:15:14 -04:00
// formatPaneSnapshot emits one absolute row address per line, then the caret.
// Plain captures lack caret evidence and cannot prove invisible trailing edits.
// eslint-disable-next-line no-control-regex
const positions = [... snapshot . matchAll ( /\x1b\[(\d+);(\d+)H/g )];
if ( positions . length < 2 ) return null ;
const caret = positions . at ( - 1 ) ! ;
if ( caret . index ! + caret [ 0 ]. length !== snapshot . length ) return null ;
const rows : string [] = [];
for ( let i = 0 ; i < positions . length - 1 ; i ++ ) {
const position = positions [ i ];
if ( Number ( position [ 1 ]) !== i + 1 || position [ 2 ] !== '1' ) return null ;
rows . push ( snapshot . slice ( position . index ! + position [ 0 ]. length , positions [ i + 1 ]. index ));
}
2026-10-24 22:54:17 -04:00
const composer = readGuardedComposer ( rows . join ( '\n' ), chrome );
2026-10-23 14:15:14 -04:00
if ( composer === null ) return null ;
2026-10-24 22:54:17 -04:00
const isComposer = composerRow ( chrome );
let row = - 1 ;
2026-10-23 14:15:14 -04:00
for ( let i = rows . length - 1 ; i >= 0 ; i -- )
2026-10-24 22:54:17 -04:00
if ( isComposer . test ( stripAnsi ( rows [ i ]))) {
row = i ;
2026-10-23 14:15:14 -04:00
break ;
}
2026-10-24 22:54:17 -04:00
const prefix = stripAnsi ( rows [ row ]). match ( new RegExp ( String . raw `^( \ s* ${ escapeRegex ( chrome . promptGlyph ) } [ \ u00a0])` ));
if ( ! prefix || Number ( caret [ 1 ]) !== row + 1 || Number ( caret [ 2 ]) !== visibleWidth ( prefix [ 1 ] + composer ) + 1 )
2026-10-23 14:15:14 -04:00
return null ;
2026-10-23 16:22:46 -04:00
return { composer , prefixWidth : visibleWidth ( prefix [ 1 ]) };
2026-10-23 14:15:14 -04:00
}
export class GuardedStageStore {
private readonly receipts = new Map < string , Receipt >();
private readonly busy = new Set < string >();
2026-10-25 09:22:35 -04:00
private readonly settleTimeoutMs : number ;
private readonly settleIntervalMs : number ;
private readonly sleep : ( ms : number ) => Promise < void >;
2026-10-23 14:15:14 -04:00
constructor (
private readonly now = Date . now ,
private readonly capacity = 1024 ,
2026-10-25 09:22:35 -04:00
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 )));
}
2026-10-23 14:15:14 -04:00
async stage ( target : GuardedInputTarget , owner : string , message : string ) {
// eslint-disable-next-line no-control-regex
const hasControls = /[\x00-\x1f\x7f-\x9f]/ . test ( message );
if (
! message ||
message !== message . trimEnd () ||
Buffer . byteLength ( message ) > STAGE_MAX_BYTES ||
Buffer . from ( message ). toString ( 'utf8' ) !== message ||
hasControls
)
throw new GuardedInputConflict (
'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace'
);
2026-10-25 09:22:35 -04:00
const chrome = target . chrome ();
const refusal = chrome && composerTriggerRefusal ( message , chrome );
if ( refusal ) throw new GuardedInputConflict ( refusal );
2026-10-23 14:15:14 -04:00
const key = target . id ;
if ( this . busy . has ( key )) throw new GuardedInputConflict ( 'Another guarded input operation is in progress' );
this . busy . add ( key );
try {
const before = target . snapshot ();
if ( ! before || before . composerSha !== promptSha256 ( '' ))
throw new GuardedInputConflict ( 'An empty, verifiable composer is required' );
2026-10-23 16:22:46 -04:00
if ( ! Number . isFinite ( before . availableColumns ) || visibleWidth ( message ) >= before . availableColumns )
throw new GuardedInputConflict ( 'Message would reach the pane edge or wrap; shorten it or widen the pane' );
2026-10-23 14:15:14 -04:00
for ( const [ id , receipt ] of this . receipts )
if ( receipt . expiresAt <= this . now () || receipt . sessionId === target . id ) this . receipts . delete ( id );
// Refuse capacity before typing. Do not evict a caller's still-live receipt.
// Include in-flight stages (this operation already owns one busy slot),
// so different sessions cannot type concurrently into the same last slot.
if ( this . receipts . size + this . busy . size > this . capacity )
throw new GuardedInputConflict ( 'Guarded stage receipt capacity reached' );
if ( ! ( await target . write ( message )))
2026-10-25 09:22:35 -04:00
throw new GuardedInputConflict ( `Staging delivery failed or ambiguous; ${ MAY_BE_TYPED } ` );
const typedAt = this . now ();
const after = await this . settle ( target , before , promptSha256 ( message ));
2026-10-23 14:15:14 -04:00
const stageId = randomUUID ();
const expiresAt = this . now () + this . ttlMs ;
const sha256 = promptSha256 ( message );
2026-10-25 09:22:35 -04:00
this . receipts . set ( stageId , { ... after , owner , sessionId : target.id , expiresAt , sha256 , typedAt });
2026-10-23 14:15:14 -04:00
return { stageId , sha256 , expiresAt , sessionId : target.id , submitted : false as const };
} finally {
this . busy . delete ( key );
}
}
2026-10-25 09:22:35 -04:00
/**
* 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 );
}
}
2026-10-23 14:15:14 -04:00
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 )
throw new GuardedInputConflict ( 'Unknown, used, or foreign stage receipt' );
// One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter.
this . receipts . delete ( stageId );
if ( this . busy . has ( target . id )) throw new GuardedInputConflict ( 'Another guarded input operation is in progress' );
this . busy . add ( target . id );
try {
if ( receipt . expiresAt <= this . now () || receipt . sha256 !== confirmSha )
throw new GuardedInputConflict ( 'Expired stage receipt or confirmation hash mismatch' );
2026-10-25 09:22:35 -04:00
const quiet = receipt . typedAt + SUBMIT_QUIET_MS - this . now ();
if ( quiet > 0 ) await this . sleep ( quiet );
2026-10-23 14:15:14 -04:00
const current = target . snapshot ();
if (
! current ||
current . generation !== receipt . generation ||
current . revision !== receipt . revision ||
2026-10-23 16:22:46 -04:00
current . availableColumns !== receipt . availableColumns ||
2026-10-23 14:15:14 -04:00
current . composerSha !== receipt . sha256
)
throw new GuardedInputConflict ( 'Staged composer, input, or pane identity changed; nothing submitted' );
if ( ! ( await target . submit ( current )))
throw new GuardedInputConflict ( 'Submission delivery failed or ambiguous; receipt consumed, inspect the pane' );
return { stageId , sessionId : target.id , submitted : true as const };
} finally {
this . busy . delete ( target . id );
}
}
clear () : void {
this . receipts . clear ();
this . busy . clear ();
}
}