Files
Codeman/src/web/session-wait-registry.ts
T
Codeman maintainer d26f26fe34 chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:18:03 +02:00

1090 lines
44 KiB
TypeScript

/**
* @fileoverview Blocking-wait registry backing the agent wait primitives.
*
* Codeman's only "tell me when" channel today is SSE, which an agent driving the
* API from a shell tool cannot practically consume (it would have to hold a
* streaming connection and parse events inline). This registry is the piece that
* lets a request *block* until something happens instead, so an orchestrating
* agent can `curl` and wait. Plan: `docs/agent-control-plan.md`.
*
* Two waiter kinds:
* - **signal waiters** resolve on the first of a requested set of lifecycle
* signals (`idle`, `working`, `stop`, `blocked`, `exit`).
* - **output waiters** resolve when a literal string appears in a session's
* ANSI-stripped output.
*
* ## What "ANSI-stripped output" means here
*
* `normalizeForMatch()` defines it, and it is a superset of `stripAnsi()`: that helper
* knows three escape families and leaves the rest, which is enough for `ESC ( B` from a
* stock bash prompt to break `match=tnode:` on a prompt that reads `tnode:`. Everything
* downstream derives from one call to it, so the carry, the haystack and the snippet
* window are all the same text. The returned `snippet` is then RENDERED from that window
* (control bytes removed, blank runs collapsed) and so is not a byte-for-byte quotation
* of what was matched — see `findMatch`.
*
* ## Signal provenance (why `stop` is the good one)
*
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
* `blocked` never fire for anything else: callers must be rejected up front rather
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
* `shell` is not an external CLI yet installs no hooks either.
*
* ## Ordering contract for the wiring
*
* A session that exits must `notifySignal(id, 'exit')` BEFORE `cancelAll(id)`, so
* a caller waiting on `exit` gets `signal: 'exit'` rather than `ended: true`.
*
* ## Lifetime discipline (24h sessions)
*
* Every waiter owns exactly one timer, cleared on resolve, and waiter sets are
* deleted when they empty. Session teardown must call `cancelAll()` and shutdown
* `stop()`: timers are deliberately NOT unref'd, because an unref'd timer can let
* the process exit mid-wait and strand the HTTP response.
*
* Two things keep a slot from outliving its caller:
* - `abortSignal`, so a client that hangs up frees its waiter immediately instead of
* holding one for the rest of its timeout. Routes wire it to `req.raw.on('close')`.
* - the latched stopped flag, so a request that lands in the window between shutdown
* starting and the HTTP server actually closing cannot register a fresh waiter that
* nothing will ever cancel.
*
* This module holds no IO and no `Session` reference, which is what keeps it
* unit-testable in isolation.
*/
import { stripAnsi } from '../utils/index.js';
import {
MAX_WAITERS_PER_SESSION,
MAX_WAITERS_PER_OWNER,
MAX_WAITERS_TOTAL,
MAX_SNIPPET_CONTEXT,
} from '../config/agent-wait.js';
import type { SessionMode, SessionStatus } from '../types.js';
// ─── Signals ─────────────────────────────────────────────────────────────────
/** A lifecycle signal a caller can block on. */
export type WaitSignal = 'idle' | 'working' | 'stop' | 'blocked' | 'exit';
/** Every valid signal, in documentation order. */
export const WAIT_SIGNALS: readonly WaitSignal[] = ['idle', 'working', 'stop', 'blocked', 'exit'];
/**
* Applied when a caller omits `until`. `stop` first because it is the definitive
* end-of-turn signal, `idle` as the fallback for sessions that emit no hooks, and
* `exit` so a worker that CRASHES resolves the wait promptly instead of burning
* the caller's whole timeout on something that can no longer happen.
*/
export const DEFAULT_WAIT_SIGNALS: readonly WaitSignal[] = ['stop', 'idle', 'exit'];
const SIGNAL_SET = new Set<string>(WAIT_SIGNALS);
/** Result of parsing a caller-supplied `until` value. */
export interface ParsedWaitSignals {
/** Valid signals, deduped, in the order given. */
signals: WaitSignal[];
/** Tokens that are not signals. Non-empty means the caller should get a 400. */
invalid: string[];
}
/**
* Parse an `until` query value: a comma-separated string, an array of strings, or
* absent. Invalid tokens are REPORTED rather than dropped: silently falling back
* to the default would leave an agent believing it is waiting for `stop` when a
* typo means it is waiting for something else entirely.
*
* @param raw - `"stop,idle"`, `["stop","idle"]`, or undefined
* @returns valid signals plus any unrecognized tokens
*/
export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
const tokens: string[] = [];
if (typeof raw === 'string') {
tokens.push(...raw.split(','));
} else if (Array.isArray(raw)) {
for (const entry of raw) {
if (typeof entry === 'string') tokens.push(...entry.split(','));
}
}
const signals: WaitSignal[] = [];
const invalid: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
const value = token.trim().toLowerCase();
if (!value) continue;
if (!SIGNAL_SET.has(value)) {
if (!invalid.includes(value)) invalid.push(value);
continue;
}
if (seen.has(value)) continue;
seen.add(value);
signals.push(value as WaitSignal);
}
return { signals, invalid };
}
/**
* The signal a session is *already* emitting, given its status, so a wait can
* resolve immediately instead of hanging until something changes.
*
* ⚠️ **What this CANNOT tell you: whether the session is alive.** It sees only the
* four-value `SessionStatus` enum, and that enum does not distinguish a finished
* worker from a dead one. Both PTY `onExit` handlers in `session.ts` (Claude
* interactive and shell) park the session at `'idle'`, and a PTY exit does not remove
* the session from the map, so a crashed worker reports `'idle'` indefinitely and this
* function will answer `'idle'` for it forever. `'stopped'` is set only on a spawn
* failure and by `stop(killMux:true)` (which deletes the session moments later), so in
* practice the `'stopped' → 'exit'` arm almost never fires for a session that merely
* died.
*
* Read the return value as "what state is it in", never as "is it still running". A
* caller that must tell a completed turn from a corpse has to consult liveness itself
* (`session.pid`, which is null after a PTY exit) and cannot get it from here. The
* live `exit` signal is still delivered by `notifySignal(id, 'exit')` at the moment
* the PTY dies; this function is only about the state a LATER caller finds.
*
* `stopped` and `error` both map to `exit` because both mean "not running", so a
* caller that does catch one of them does not block. `blocked` is deliberately
* underivable here, it exists only as a hook event until it becomes a real
* `SessionStatus` (see the deferred Part 3 in the plan).
*/
export function signalForStatus(status: SessionStatus): WaitSignal | null {
switch (status) {
case 'idle':
return 'idle';
case 'busy':
return 'working';
case 'stopped':
case 'error':
return 'exit';
default:
return null;
}
}
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/**
* Whether a session in this mode ever POSTs Codeman hook events, and therefore
* whether `stop` / `blocked` can ever fire for it.
*
* True for `claude` and nothing else. The tempting predicate is
* `!isExternalCliMode(mode)`, and it is WRONG: that helper covers only
* opencode/codex/gemini/antigravity, so `shell` falls through it — and a shell session
* is a plain bash PTY with no Claude Code and no hooks installed. `until=stop` on one
* was accepted and then blocked for the caller's whole timeout, which is precisely the
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
*/
export function hooksAvailableForMode(mode: SessionMode): boolean {
return mode === 'claude';
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
export interface ResolvedWaitSignals {
/** Signals to actually wait on. Empty when `error` is set. */
until: WaitSignal[];
/** Caller-facing 400 message, or null when the request is usable. */
error: string | null;
}
/**
* Turn a raw `until` / `wait` value into the set to wait on, applying both rules
* every wait endpoint shares:
*
* 1. An unknown token is an ERROR, never a silent fallback to the default.
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
* never 400.
*
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
* not drift; the second-guessing that produces is worse than the duplication.
*
* @param raw - the caller's value (comma string, array, `true` for "the default")
* @param options - `mode` decides whether the hook-only signals are available, and
* names the mode in the error message so the caller can see why
*/
export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }): ResolvedWaitSignals {
const parsed = parseWaitSignals(raw);
if (parsed.invalid.length > 0) {
return {
until: [],
error: `Unknown wait signal(s): ${parsed.invalid.join(', ')}. Valid: ${WAIT_SIGNALS.join(', ')}`,
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode) ? [] : HOOK_ONLY_SIGNALS);
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
}
const rejected = parsed.signals.filter((signal) => unsupported.has(signal));
if (rejected.length > 0) {
return {
until: [],
error: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
};
}
return { until: parsed.signals, error: null };
}
// ─── Results ─────────────────────────────────────────────────────────────────
/** Fields every wait result carries, whichever kind it is. */
interface WaitResultBase {
/** True when the wait hit its timeout. */
timedOut: boolean;
/** True when the answer came from state already present at call time. */
immediate: boolean;
/** True when the session went away (deleted / torn down) before the wait resolved. */
ended: boolean;
/**
* True when the caller's `abortSignal` fired: the client hung up, so nobody is
* reading this result. `ended` is set alongside it, since the wait did end without
* an answer. False on every other path.
*/
aborted: boolean;
/** Wall-clock ms spent waiting (0 for an immediate resolve). */
waitedMs: number;
/**
* The timeout actually applied, after clamping. Echoed because a caller that asked
* for 30 minutes and silently got 600s otherwise reads the timeout as a stalled
* worker and kills a session that was working fine.
*/
timeoutMs: number;
}
/** Outcome of a signal wait. A timeout is a normal outcome, never an error. */
export interface SignalWaitResult extends WaitResultBase {
/** The signal that fired, or null if the wait ended without one. */
signal: WaitSignal | null;
}
/** Outcome of an output wait. */
export interface OutputWaitResult extends WaitResultBase {
matched: boolean;
/** Bounded window of output around the match, or null if nothing matched. */
snippet: string | null;
}
/**
* Thrown when a waiter would exceed a configured cap.
*
* `scope` is what the route needs to answer honestly: `'session'` is the caller's own
* session being oversubscribed (409 SESSION_BUSY), while `'owner'` and `'total'` are
* caps the caller may have no part in and cannot fix by switching sessions, so those
* map to 429 RATE_LIMITED. Reporting a process-wide cap as SESSION_BUSY tells the
* caller the wrong session is at fault.
*/
export class WaitCapacityError extends Error {
override readonly name = 'WaitCapacityError';
constructor(
message: string,
/** Which cap was hit, so the route can say something useful. */
readonly scope: 'session' | 'owner' | 'total'
) {
super(message);
}
}
// ─── Options ─────────────────────────────────────────────────────────────────
/** Options shared by both wait kinds. */
interface WaitOptionsBase {
/** Timeout in ms. Callers pass the CLAMPED value; it is echoed back in the result. */
timeoutMs: number;
/**
* Owner to charge this waiter to (multi-user mode). Omit in single-user mode: the
* per-owner cap applies only when this is set, so leaving it undefined behaves
* exactly as if the cap did not exist.
*/
owner?: string;
/**
* Fires when the caller goes away (routes wire this to `req.raw.on('close')`).
* The waiter is removed and its timer cleared at once, resolving `ended: true,
* aborted: true`. Freeing the slot is the entire point: the response can no longer
* be sent, so holding one for the rest of the timeout only denies the pool to
* someone else. An already-aborted signal registers no waiter at all.
*/
abortSignal?: AbortSignal;
}
export interface SignalWaitOptions extends WaitOptionsBase {
/** Signals to wait for; the first to fire wins. An empty set can only time out. */
until: readonly WaitSignal[];
/**
* When true, ignore `currentSignal` and require an actual transition. This is
* the `fresh=1` behavior: "tell me about the NEXT one", not "is it already so".
*/
requireTransition?: boolean;
/** The session's current signal (from `signalForStatus`), if known. */
currentSignal?: WaitSignal | null;
}
export interface OutputWaitOptions extends WaitOptionsBase {
/** Literal substring to look for. No regex: see the note on `waitForOutput`. */
match: string;
/** Case-insensitive compare. */
nocase?: boolean;
/**
* Existing buffered output to scan before waiting (the `from=buffer` mode).
* Callers should pass a BOUNDED slice: a session's text buffer can be tens of
* megabytes and this is scanned synchronously. `assertCapacity()` runs BEFORE this
* is touched, so a request that cannot get a slot never pays for the scan.
*/
initialText?: string;
}
export interface SessionWaitRegistryOptions {
maxWaitersPerSession?: number;
maxWaitersPerOwner?: number;
maxWaitersTotal?: number;
}
// ─── Internal waiter records ─────────────────────────────────────────────────
/** Bookkeeping every waiter carries, whichever kind it is. */
interface WaiterBase {
startedAt: number;
timer: NodeJS.Timeout;
/** Effective timeout, echoed back in the result. */
timeoutMs: number;
/** Owner this waiter is charged to, if any. */
owner?: string;
/** Detaches the abort listener; called on every removal path. */
detachAbort?: () => void;
}
interface SignalWaiter extends WaiterBase {
until: Set<WaitSignal>;
settle: (result: SignalWaitResult) => void;
}
interface OutputWaiter extends WaiterBase {
/** Original-case needle, kept for reporting. */
needle: string;
/** Comparison form of the needle (lowercased when nocase). */
needleCmp: string;
nocase: boolean;
/** Tail of previously scanned text, so a match can straddle two chunks. */
carry: string;
settle: (result: OutputWaitResult) => void;
}
// ─── Registry ────────────────────────────────────────────────────────────────
/**
* Holds pending waiters keyed by session id.
*
* One instance is shared process-wide (`sessionWaits` below); tests construct
* their own so no state leaks between cases.
*/
export class SessionWaitRegistry {
private readonly signalWaiters = new Map<string, Set<SignalWaiter>>();
private readonly outputWaiters = new Map<string, Set<OutputWaiter>>();
/**
* Live waiter count per owner. A derived count would mean walking every waiter in
* the process on each registration, so it is maintained incrementally instead:
* incremented once at registration, decremented once in the remove* helpers, which
* are the only paths that take a waiter out of a set.
*/
private readonly ownerCounts = new Map<string, number>();
/**
* Trailing bytes of a PTY chunk that look like the START of an ANSI sequence whose
* terminator has not arrived yet, held back until the next chunk completes it. Keyed
* by session because every output waiter on a session sees the same stream, which
* keeps `notifyOutput` at ONE `stripAnsi` per chunk rather than one per waiter.
* Deleted with the session's waiter set.
*/
private readonly pendingAnsi = new Map<string, string>();
private readonly maxPerSession: number;
private readonly maxPerOwner: number;
private readonly maxTotal: number;
/** Latched by `stop()` / `cancelEverything()`. Never cleared: shutdown is one-way. */
private stopped = false;
constructor(options: SessionWaitRegistryOptions = {}) {
this.maxPerSession = options.maxWaitersPerSession ?? MAX_WAITERS_PER_SESSION;
this.maxPerOwner = options.maxWaitersPerOwner ?? MAX_WAITERS_PER_OWNER;
this.maxTotal = options.maxWaitersTotal ?? MAX_WAITERS_TOTAL;
}
// ── Counts (also the cap accounting) ──
/** Pending signal waiters for a session. */
signalWaiterCount(sessionId: string): number {
return this.signalWaiters.get(sessionId)?.size ?? 0;
}
/** Pending output waiters for a session. */
outputWaiterCount(sessionId: string): number {
return this.outputWaiters.get(sessionId)?.size ?? 0;
}
/** Pending waiters of both kinds for a session. */
waiterCount(sessionId: string): number {
return this.signalWaiterCount(sessionId) + this.outputWaiterCount(sessionId);
}
/** Pending waiters of both kinds across every session. */
totalWaiterCount(): number {
let total = 0;
for (const set of this.signalWaiters.values()) total += set.size;
for (const set of this.outputWaiters.values()) total += set.size;
return total;
}
/** Pending waiters of both kinds charged to one owner. */
ownerWaiterCount(owner: string): number {
return this.ownerCounts.get(owner) ?? 0;
}
/** True once `stop()` / `cancelEverything()` has run: no new waiter can register. */
get isStopped(): boolean {
return this.stopped;
}
/**
* Throw if registering one more waiter would exceed a cap.
*
* PUBLIC so a route can check BEFORE doing expensive setup work. `/wait-output`'s
* `from=buffer` mode is the case that matters: reading `session.terminalBuffer`
* joins the whole 32MB accumulator, and paying that for a request that is about to
* be refused turns the cap into an amplifier instead of a protection. Both
* `waitForSignal` and `waitForOutput` call it themselves too, so a caller that skips
* the pre-check is still bounded.
*
* @throws {WaitCapacityError} naming the cap that was hit in `scope`.
*/
assertCapacity(sessionId: string, owner?: string): void {
if (this.totalWaiterCount() >= this.maxTotal) {
throw new WaitCapacityError(`Too many concurrent waits (scope: total, max ${this.maxTotal})`, 'total');
}
if (owner !== undefined && this.ownerWaiterCount(owner) >= this.maxPerOwner) {
throw new WaitCapacityError(
`Too many concurrent waits for this user (scope: owner, max ${this.maxPerOwner})`,
'owner'
);
}
if (this.waiterCount(sessionId) >= this.maxPerSession) {
throw new WaitCapacityError(
`Too many concurrent waits on this session (scope: session, max ${this.maxPerSession})`,
'session'
);
}
}
private chargeOwner(owner: string | undefined): void {
if (owner === undefined) return;
this.ownerCounts.set(owner, (this.ownerCounts.get(owner) ?? 0) + 1);
}
private releaseOwner(owner: string | undefined): void {
if (owner === undefined) return;
const next = (this.ownerCounts.get(owner) ?? 0) - 1;
if (next > 0) this.ownerCounts.set(owner, next);
else this.ownerCounts.delete(owner);
}
/**
* Wire a caller's abort signal to `onAbort`, returning the detach function.
* Detaching matters even though request signals are short-lived: without it a
* caller reusing one controller across several waits accumulates listeners on it.
*/
private attachAbort(signal: AbortSignal | undefined, onAbort: () => void): (() => void) | undefined {
if (!signal) return undefined;
signal.addEventListener('abort', onAbort, { once: true });
return () => signal.removeEventListener('abort', onAbort);
}
// ── Signal waits ──
/**
* Block until one of `until` fires, the timeout elapses, or the session ends.
*
* Resolves immediately (`immediate: true`) when the session's `currentSignal` is
* already in `until` and `requireTransition` is not set.
*
* @throws {WaitCapacityError} when a waiter cap is already reached.
*/
waitForSignal(sessionId: string, options: SignalWaitOptions): Promise<SignalWaitResult> {
const until = new Set(options.until);
const base = { signal: null, timedOut: false, immediate: false, waitedMs: 0, timeoutMs: options.timeoutMs };
// Nobody is listening / nothing can ever resolve: answer without taking a slot,
// and without a capacity throw the caller could not act on either way.
if (options.abortSignal?.aborted) return Promise.resolve({ ...base, ended: true, aborted: true });
if (this.stopped) return Promise.resolve({ ...base, ended: true, aborted: false });
if (!options.requireTransition && options.currentSignal && until.has(options.currentSignal)) {
return Promise.resolve({
signal: options.currentSignal,
timedOut: false,
immediate: true,
ended: false,
aborted: false,
waitedMs: 0,
timeoutMs: options.timeoutMs,
});
}
this.assertCapacity(sessionId, options.owner);
return new Promise<SignalWaitResult>((resolve) => {
let set = this.signalWaiters.get(sessionId);
if (!set) {
set = new Set<SignalWaiter>();
this.signalWaiters.set(sessionId, set);
}
const waiter: SignalWaiter = {
until,
startedAt: Date.now(),
timeoutMs: options.timeoutMs,
owner: options.owner,
timer: setTimeout(() => {
this.removeSignalWaiter(sessionId, waiter);
resolve({
signal: null,
timedOut: true,
immediate: false,
ended: false,
aborted: false,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
}, options.timeoutMs),
settle: resolve,
};
set.add(waiter);
this.chargeOwner(options.owner);
waiter.detachAbort = this.attachAbort(options.abortSignal, () => {
this.removeSignalWaiter(sessionId, waiter);
resolve({
signal: null,
timedOut: false,
immediate: false,
ended: true,
aborted: true,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
});
});
}
/**
* Deliver a lifecycle signal. Every waiter for this session that asked for it
* resolves; the rest keep waiting.
*
* @returns how many waiters were resolved (0 is the overwhelmingly common case,
* which is why this is safe to call from the session event hot path).
*/
notifySignal(sessionId: string, signal: WaitSignal): number {
const set = this.signalWaiters.get(sessionId);
if (!set || set.size === 0) return 0;
// Snapshot: settling mutates the set.
let resolved = 0;
for (const waiter of [...set]) {
if (!waiter.until.has(signal)) continue;
this.removeSignalWaiter(sessionId, waiter);
waiter.settle({
signal,
timedOut: false,
immediate: false,
ended: false,
aborted: false,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
resolved++;
}
return resolved;
}
private removeSignalWaiter(sessionId: string, waiter: SignalWaiter): void {
clearTimeout(waiter.timer);
waiter.detachAbort?.();
const set = this.signalWaiters.get(sessionId);
if (!set) return;
// Guard on the delete: a second removal (abort racing a resolve) must not
// decrement the owner count twice and hand out a slot that is still in use.
if (!set.delete(waiter)) return;
this.releaseOwner(waiter.owner);
if (set.size === 0) this.signalWaiters.delete(sessionId);
}
// ── Output waits ──
/**
* Block until `match` appears in the session's output.
*
* **Literal matching only, deliberately.** `search-service.ts` avoids regex so
* there is no ReDoS surface, and this endpoint is more exposed still: the
* pattern is caller-supplied and the input is a live stream. herdr can offer
* `--regex` because Rust's regex crate is linear-time with no backtracking;
* JavaScript's `RegExp` backtracks.
*
* ⚠️ "New output" is not the same as "output produced after you asked". tmux
* REPAINTS the visible screen (on attach, resize, or a TUI redraw), and a repaint
* arrives as ordinary `terminal` data, so text that was already on screen can
* match a `from=now` wait. Observed live: a marker echoed a minute earlier matched
* instantly on a fresh wait. Callers must use a marker unique to the call
* (`echo DONE_$RANDOM`), not a generic one like `BUILD OK`.
*
* @throws {WaitCapacityError} when a waiter cap is already reached.
*/
waitForOutput(sessionId: string, options: OutputWaitOptions): Promise<OutputWaitResult> {
const nocase = options.nocase === true;
const needle = options.match;
const needleCmp = nocase ? needle.toLowerCase() : needle;
const base = {
matched: false,
timedOut: false,
immediate: false,
snippet: null,
waitedMs: 0,
timeoutMs: options.timeoutMs,
};
if (options.abortSignal?.aborted) return Promise.resolve({ ...base, ended: true, aborted: true });
if (this.stopped) return Promise.resolve({ ...base, ended: true, aborted: false });
// Capacity FIRST, deliberately: the `from=buffer` scan below strips ANSI over (and
// for nocase lowercases again) up to MAX_BUFFER_SCAN_BYTES, and the route has
// already joined the session's whole terminal buffer to produce it. Doing that for
// a request that is about to be refused is unbounded work at request rate with the
// caps providing no backpressure at all.
this.assertCapacity(sessionId, options.owner);
// `from=buffer`: scan what is already there before blocking.
let carry = '';
if (options.initialText) {
const text = normalizeForMatch(options.initialText);
const hit = findMatch(text, needleCmp, nocase);
if (hit) {
return Promise.resolve({
matched: true,
timedOut: false,
immediate: true,
ended: false,
aborted: false,
snippet: hit,
waitedMs: 0,
timeoutMs: options.timeoutMs,
});
}
carry = tailFor(text, needleCmp.length);
}
return new Promise<OutputWaitResult>((resolve) => {
let set = this.outputWaiters.get(sessionId);
if (!set) {
set = new Set<OutputWaiter>();
this.outputWaiters.set(sessionId, set);
}
const waiter: OutputWaiter = {
needle,
needleCmp,
nocase,
carry,
startedAt: Date.now(),
timeoutMs: options.timeoutMs,
owner: options.owner,
timer: setTimeout(() => {
this.removeOutputWaiter(sessionId, waiter);
resolve({
matched: false,
timedOut: true,
immediate: false,
ended: false,
aborted: false,
snippet: null,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
}, options.timeoutMs),
settle: resolve,
};
set.add(waiter);
this.chargeOwner(options.owner);
waiter.detachAbort = this.attachAbort(options.abortSignal, () => {
this.removeOutputWaiter(sessionId, waiter);
resolve({
matched: false,
timedOut: false,
immediate: false,
ended: true,
aborted: true,
snippet: null,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
});
});
}
/**
* Feed a raw PTY chunk to this session's output waiters.
*
* Called from the always-attached `terminal` listener, so it sits on the PTY hot
* path: it must stay a single Map lookup when nobody is waiting, which is why the
* no-waiter check comes before the ANSI strip.
*
* ⚠️ A PTY read boundary can fall INSIDE an escape sequence, which is routine under
* tmux (it emits SGR runs constantly). `stripAnsi` needs a complete sequence, so a
* split one used to survive the strip, land in the carry, and split the needle: the
* same text matched or not depending on where the kernel happened to cut the read.
* The incomplete tail is therefore held back in `pendingAnsi` and stripped together
* with the next chunk. That is the same class of bug the carry buffer exists to
* solve, one level down.
*
* @returns how many waiters matched.
*/
notifyOutput(sessionId: string, chunk: string): number {
const set = this.outputWaiters.get(sessionId);
if (!set || set.size === 0) return 0;
const pending = this.pendingAnsi.get(sessionId);
const split = splitTrailingEscape(pending ? pending + chunk : chunk);
if (split.pending) this.pendingAnsi.set(sessionId, split.pending);
else if (pending !== undefined) this.pendingAnsi.delete(sessionId);
const text = normalizeForMatch(split.text);
if (!text) return 0;
let resolved = 0;
for (const waiter of [...set]) {
// Prepend the tail of what this waiter already scanned so a match spanning
// two chunks is still found. Re-scanning the carry cannot double-fire: a
// needle wholly inside the carry would have matched on the previous pass.
const hay = waiter.carry + text;
const hit = findMatch(hay, waiter.needleCmp, waiter.nocase);
if (hit) {
this.removeOutputWaiter(sessionId, waiter);
waiter.settle({
matched: true,
timedOut: false,
immediate: false,
ended: false,
aborted: false,
snippet: hit,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
resolved++;
continue;
}
waiter.carry = tailFor(hay, waiter.needleCmp.length);
}
return resolved;
}
private removeOutputWaiter(sessionId: string, waiter: OutputWaiter): void {
clearTimeout(waiter.timer);
waiter.detachAbort?.();
const set = this.outputWaiters.get(sessionId);
if (!set) return;
if (!set.delete(waiter)) return;
this.releaseOwner(waiter.owner);
if (set.size === 0) {
this.outputWaiters.delete(sessionId);
// The held-back escape tail belongs to the waiter set; with nobody watching it
// would be a per-session string kept alive for the life of the process.
this.pendingAnsi.delete(sessionId);
}
}
// ── Teardown ──
/**
* Resolve every waiter for a session with `ended: true`.
*
* Call on session deletion and PTY teardown. A session that EXITS should
* `notifySignal(id, 'exit')` first, so an `until=exit` caller sees the signal
* rather than a bare `ended`.
*
* @returns how many waiters were resolved.
*/
cancelAll(sessionId: string): number {
let resolved = 0;
const signals = this.signalWaiters.get(sessionId);
if (signals) {
for (const waiter of [...signals]) {
this.removeSignalWaiter(sessionId, waiter);
waiter.settle({
signal: null,
timedOut: false,
immediate: false,
ended: true,
aborted: false,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
resolved++;
}
}
const outputs = this.outputWaiters.get(sessionId);
if (outputs) {
for (const waiter of [...outputs]) {
this.removeOutputWaiter(sessionId, waiter);
waiter.settle({
matched: false,
timedOut: false,
immediate: false,
ended: true,
aborted: false,
snippet: null,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
resolved++;
}
}
this.pendingAnsi.delete(sessionId);
return resolved;
}
/**
* Resolve every waiter in the process and refuse every later one.
*
* Shutdown must call this: waiter timers are not unref'd, so a pending 10-minute
* wait would otherwise hold the event loop open and strand its HTTP response.
*
* ⚠️ The latch is the point, not just the sweep. `server.stop()` cancels waiters
* well before `app.close()`, with several awaits in between (orchestrator teardown,
* scheduled-run stops that each await a session stop and a tmux kill), and the
* listener is still accepting requests throughout. A `GET .../wait?timeout=600000`
* landing in that window used to register a fresh waiter that nothing would ever
* cancel, stalling shutdown for up to `MAX_WAIT_MS` — exactly the outcome cancelling
* was meant to prevent. Once latched, a wait resolves at once with `ended: true`.
*/
stop(): number {
this.stopped = true;
return this.cancelEverything();
}
/**
* Resolve every waiter in the process. Latches the stopped flag, so this is
* shutdown-shaped; `stop()` is the same call under the name that says so.
*/
cancelEverything(): number {
this.stopped = true;
let resolved = 0;
const ids = new Set([...this.signalWaiters.keys(), ...this.outputWaiters.keys()]);
for (const id of ids) resolved += this.cancelAll(id);
return resolved;
}
}
// ─── Shared helpers ──────────────────────────────────────────────────────────
/**
* Escape sequences `stripAnsi` leaves behind, removed so the text this module MATCHES
* against is what the pane visually shows.
*
* `ANSI_ESCAPE_PATTERN_FULL` covers three families — `ESC [ … letter`,
* `ESC ] … (BEL|ST)`, and `ESC =` / `ESC >` — and nothing else. The gap is not
* theoretical: a stock bash prompt emits `arkon@tnode\x1b(B\x1b[m:`, the CSI is removed
* and the charset-select `ESC ( B` is not, so `match=tnode:` **fails on a prompt that
* plainly reads `tnode:`** while `match=(B` succeeds. That is the single most likely
* thing an orchestrating agent tries, and the failure is silent (a full-length timeout).
*
* Two branches, in order:
* - `ESC P|X|^|_ … (BEL|ST)` — DCS / SOS / PM / APC string sequences. The terminator is
* REQUIRED, exactly as `stripAnsi`'s OSC branch requires one: matching an unterminated
* string type with a greedy run would delete every byte after it to the end of the
* window, which is real output an agent is waiting for.
* - `ESC <0x20-0x2f>* <0x30-0x7e>` — the ECMA-48 escape-sequence grammar (zero or more
* intermediate bytes then a final byte). This is what covers `ESC ( B`, `ESC ) 0`,
* `ESC c`, `ESC 7` / `ESC 8`, `ESC # 8` and a stray `ESC \`.
*
* Linear-time by the same argument that holds for `stripAnsi`: every starred class is
* disjoint from what follows it, and `ESC` is excluded from all of them, so two
* candidate runs can never overlap and there is no backtracking to exploit.
*
* ⚠️ This duplicates ANSI knowledge that would be better held once in
* `src/utils/regex-patterns.ts`. It lives here deliberately: `stripAnsi` has many
* consumers (respawn and usage-limit pattern matching, the terminal buffer, search) and
* widening it changes all of them at once, which is not a change to make as a side
* effect of fixing this endpoint. See the follow-up note in
* `tmp/agent-wait-review/report-fix-registry.md`.
*/
// eslint-disable-next-line no-control-regex -- matching raw terminal control bytes is the point
const ANSI_ESCAPE_RESIDUE = /\x1b(?:[PX^_][^\x07\x1b]*(?:\x07|\x1b\\)|[\x20-\x2f]*[\x30-\x7e])/g;
/**
* The text this module matches against: ANSI-stripped, then residue-stripped.
*
* Everything downstream (the carry, the haystack, the snippet window) is derived from
* the output of this function, so there is exactly one definition of "the matched
* stream" and `findMatch` never sees an escape byte.
*/
function normalizeForMatch(raw: string): string {
return stripAnsi(raw).replace(ANSI_ESCAPE_RESIDUE, '');
}
/**
* C0 controls, DEL and the C1 block, minus tab / newline / carriage return. Removed
* from the snippet so no byte that can reprogram the reading agent's terminal, or
* begin a sequence that does, survives into the JSON response.
*
* Still needed after `normalizeForMatch`, which removes escape SEQUENCES: a bare BEL,
* NUL or backspace carries no ESC and reaches the haystack intact.
*/
// eslint-disable-next-line no-control-regex -- removing raw terminal control bytes is the point
const SNIPPET_CONTROL_BYTES = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
/**
* Find `needleCmp` in `hay` and return a bounded window of surrounding text,
* or null when absent. The snippet comes from the ORIGINAL-case text even in
* nocase mode, so callers see what the terminal actually printed.
*
* ## Two texts, and they are not the same string
*
* Precision here matters, because an agent that cannot match something reaches for the
* snippet to work out why:
*
* - **Matched**: `hay`, which is `normalizeForMatch()` output — ANSI-stripped and
* residue-stripped, with nothing else touched. `matched` is decided against exactly
* this, so it is byte-for-byte what a needle must appear in.
* - **Returned**: a bounded window of that same text, then RENDERED for a human or an
* LLM to read: leftover control bytes removed, blank runs collapsed, trimmed.
*
* So the snippet is a rendering of the matched window, not a quotation of it. The
* remaining differences cannot change whether a printable needle matched, but they are
* real: a needle containing `\n\n` or a raw control byte is matchable and would not
* appear verbatim in the snippet.
*
* Blank runs are collapsed for READABILITY ONLY. A real pane pads with dozens of `\r\n`
* between the prompt and the match, which would otherwise fill the whole context window
* with nothing and make the snippet useless to the agent reading it.
*
* Control bytes are dropped because, unlike `GET .../terminal`, this field is documented
* as stripped and its consumer is an agent piping it through `jq` into its OWN terminal
* pane, so a worker printing attacker-influenced bytes could otherwise reset or garble
* the orchestrator's display. Whitespace is preserved; every other C0/C1 control and DEL
* is removed.
*/
function findMatch(hay: string, needleCmp: string, nocase: boolean): string | null {
const cmp = nocase ? hay.toLowerCase() : hay;
const idx = cmp.indexOf(needleCmp);
if (idx === -1) return null;
// ⚠️ `idx` indexes `cmp`, and the snippet is sliced out of `hay`. Those agree only
// while lowercasing preserves length, which it usually but not always does:
// 'İ' (U+0130) lowercases to TWO code units, so terminal output containing Turkish
// text, a filename or a git author line shifts every later index and the window
// slides off the match entirely. A length compare detects it for the cost of one
// integer read, so the common path stays exactly one `indexOf` and no allocation.
const drift = cmp.length - hay.length;
const start = drift === 0 ? idx : originalIndex(hay, idx, drift);
const matchEnd = drift === 0 ? idx + needleCmp.length : originalIndex(hay, idx + needleCmp.length, drift);
return hay
.slice(Math.max(0, start - MAX_SNIPPET_CONTEXT), Math.min(hay.length, matchEnd + MAX_SNIPPET_CONTEXT))
.replace(SNIPPET_CONTROL_BYTES, '')
.replace(/[\r\n]{2,}/g, '\n')
.trim();
}
/**
* Map an index in `hay.toLowerCase()` back to the equivalent index in `hay`, for the
* rare case where the two differ in length by `drift`.
*
* `f(i) = hay.slice(0, i).toLowerCase().length` is non-decreasing and satisfies
* `i <= f(i) <= i + drift` (no lowercase mapping ever shortens a string), which bounds
* the answer to `[cmpIdx - drift, cmpIdx]` and lets a binary search find it in
* `log2(drift)` steps rather than walking the string. Ties break low, so an index that
* lands mid-expansion resolves to the character that expanded, which keeps the window
* around the match rather than past it.
*/
function originalIndex(hay: string, cmpIdx: number, drift: number): number {
let lo = Math.max(0, cmpIdx - drift);
let hi = Math.min(hay.length, cmpIdx);
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (hay.slice(0, mid).toLowerCase().length <= cmpIdx) lo = mid;
else hi = mid - 1;
}
return lo;
}
/**
* Tail of `text` to carry into the next chunk. Long enough to complete a straddling
* match and to give a later snippet some leading context.
*
* Callers pass the COMPARISON needle's length, not the original's: under `nocase` the
* text that matches can be longer than the needle as typed (a needle of `İ` compares
* as two code units, so two code units of pane output can satisfy one needle
* character), and a carry sized from the shorter one drops a straddling match.
*/
function tailFor(text: string, needleLength: number): string {
const keep = Math.max(needleLength - 1, MAX_SNIPPET_CONTEXT);
return keep >= text.length ? text : text.slice(text.length - keep);
}
/**
* Longest tail held back as a possibly-incomplete escape sequence.
*
* Generous enough for a real OSC title (`ESC ] 0 ; <path> BEL`) to survive a chunk
* split, small enough that an unterminated sequence cannot withhold meaningful output:
* past this the tail is treated as ordinary text and released, so a malformed stream
* degrades to the old behavior instead of stalling every match on the session.
*/
const MAX_PENDING_ESCAPE = 512;
/**
* A tail that is a strict PREFIX of a sequence `normalizeForMatch` would remove: a lone
* ESC, an unterminated `ESC [ …`, an unterminated string type (`ESC ] P X ^ _ …`), or
* intermediate bytes still waiting for their final byte (`ESC (` before its `B`).
* Anything else is either complete or not an escape at all, and both are safe to strip
* now.
*
* ⚠️ The third branch must stay in lockstep with `ANSI_ESCAPE_RESIDUE`'s second one.
* When the residue pass learned about `ESC ( B`, a chunk cut between the `(` and the `B`
* became a NEW way to smuggle an escape into the haystack, since neither half is a
* complete sequence on its own — the same bug one level down, which is what this
* function exists to prevent.
*
* Deliberately without the `g` flag: a global pattern would carry `lastIndex` between
* calls (see the repo-wide global-regex hazard) and this runs once per PTY chunk.
*/
// eslint-disable-next-line no-control-regex -- matching raw terminal control bytes is the point
const INCOMPLETE_ANSI_TAIL = /^\x1b(?:\[[0-9;?]*|[\]PX^_][^\x07\x1b]*|[\x20-\x2f]*)$/;
/**
* Split a raw chunk into the part that is safe to ANSI-strip now and a trailing
* fragment to hold for the next chunk.
*
* Only the LAST `ESC` can be incomplete: anything before it is followed by an escape
* introducer, so `stripAnsi` will consume or reject it on this pass either way.
*/
function splitTrailingEscape(raw: string): { text: string; pending: string } {
const esc = raw.lastIndexOf('\x1b');
if (esc === -1) return { text: raw, pending: '' };
const tail = raw.slice(esc);
if (tail.length > MAX_PENDING_ESCAPE || !INCOMPLETE_ANSI_TAIL.test(tail)) {
return { text: raw, pending: '' };
}
return { text: raw.slice(0, esc), pending: tail };
}
/**
* Process-wide registry used by the routes and the session/hook wiring.
* Tests should construct their own `SessionWaitRegistry` instead of touching this.
*/
export const sessionWaits = new SessionWaitRegistry();