// Supervisor DAEMON — holds the pty + child + headless-xterm model + capability responder // independent of any terminal; clients attach/detach over a unix socket. It HOSTS tui/router peers // by default (the pty-host launch path; opt-out per-peer via a `.no-pty-host` marker), and is also // reachable manually via the `supervisor` CLI verb for throwaway validation. It runs in its OWN // detached process, dynamic-imported from the launch path (supervisor/index.ts → daemon.ts), so // @xterm never loads in the central router's delivery path nor in the launch/lifecycle process. // // Loads @xterm/headless as the authoritative model (boot-driver reads it; capability queries answered // off it). The attach catch-up sends a SNAPSHOT of that resolved model (scrollback + viewport as // committed text rows; see repaint) — NOT a replay of the child's raw frames and NOT a SerializeAddon // dump, both of which corrupt scrollback for a relative-cursor, no-scroll-region TUI like claude. // @xterm/headless bundles under `bun build --compile` (pure JS; the Bun-native pty has no native addon). import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs' import { dirname, join } from 'node:path' import { BackpressureWriter, FRAME_DATA, FRAME_RESIZE, FRAME_RELOAD, FRAME_INPUT_ACK, FRAME_ERROR, capabilityResponses, frame, makeFramer, parseSize, splitDanglingEscape, stripQueries, } from './protocol.ts' import { formatEventLine } from '../storage/rotatelog.ts' import { attachedPath, geometryPath, inputProtocolPath, pidPath, pidStartToken, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts' import { nativeIdle } from './nativeIdle.ts' import { opencodeControl } from '../opencode/control.ts' import type { ReloadReply } from './reload.ts' import { newStabilityGate, nagSignature, nextBootAction, nextNagAction, signatureStable, type BootPredicates, type NagPredicate } from './boot.ts' import { modelToPlainText } from './render.ts' import { codexAdapter } from '../launch/adapters/codex.ts' import { claudeAdapter } from '../launch/adapters/claude.ts' import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts' import { routeCircuitBreaker } from './approvalRoute.ts' import { killPtyDescendants } from './processTree.ts' import { recordOpencodeStopped } from '../opencode/journal.ts' /** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER__BIN, the same * knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns * undefined for an unknown runtime, incl. the hermetic `tick` test stream (which uses TICK_CMD, so the * spawn/attach/reattach tests never need a real claude/codex on the box). */ function knownLauncher(runtime: string): string | undefined { const override = runtime === 'claude' ? process.env.IAPEER_CLAUDE_BIN : runtime === 'codex' ? process.env.IAPEER_CODEX_BIN : undefined if (override?.trim()) return override.trim() return Bun.which(runtime) ?? undefined } const TICK_CMD = ['sh', '-c', 'i=0; while true; do printf "STREAMTICK_%d\\r\\n" "$i"; i=$((i+1)); sleep 0.3; done'] // Boot-driver (serving slice a): the runtimes whose startup dialogs the supervisor knows how to // answer off its model. 'tick' (and any unknown runtime) has no entry → no driver → the hermetic // spawn/attach tests and the bare conduit are untouched. const BOOT_ADAPTERS: Record = { codex: codexAdapter, claude: claudeAdapter } // SCROLLBACK SPINNER STRIP (serve-side, repaint snapshot only). claude's TUI redraws its bottom status // block WITHOUT a scroll-region (DECSTBM), so as the conversation scrolls, stale spinner frames // ("✶ Gerund… (Nm Ns · ↓ N tokens)" / "… (esc to interrupt)") get pushed into the @xterm scrollback ring // and ACCUMULATE — surfacing as repeated/broken lines when a reattaching operator scrolls history UP // (the live viewport is clean; only the served history is polluted). We drop those lines from the // SCROLLBACK part of the serialized repaint snapshot, keeping the last `viewportRows` segments (the LIVE // status block, incl. the current spinner) verbatim. Matched by STRUCTURE — a gerund + … + the // elapsed/token or esc-to-interrupt tail — NOT a word-list (claude rotates 100+ spinner gerunds). The // live byte stream, pane-log and transcript are untouched; only the human-attach repaint is filtered. const SPINNER_STATUS_LINE = /…[^\n]{0,48}\((?:esc to interrupt|\d+m? ?\d*s ?·)/ const ANSI_SEQ = /\x1b\[[0-9;:?]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g export function stripSpinnerScrollback(serialized: string, viewportRows: number): string { const segs = serialized.split('\r\n') if (segs.length <= viewportRows) return serialized // no scrollback to clean const scrollbackEnd = segs.length - viewportRows // keep the last viewportRows (live viewport) verbatim const out: string[] = [] for (let i = 0; i < segs.length; i++) { if (i < scrollbackEnd && SPINNER_STATUS_LINE.test(segs[i].replace(ANSI_SEQ, ''))) continue out.push(segs[i]) } return out.join('\r\n') } // Poll cadence mirrors the launch primitive's proven 2 s boot loop (index.ts:377). The supervisor // model is strictly FRESHER than capture-pane (bytes land in @xterm synchronously as they arrive), // so 2 s leaves ample repaint time — the claude resume-picker never over-Downs, since bootDialogKeys // returns Enter (not another Down) the moment the moved cursor renders '❯ 2.'. A swallowed key // self-heals on the next tick (re-sent while the dialog is still up), same as the launch loop. const BOOT_POLL_MS = 2000 // Stop driving after this long even if readiness is never observed (a hung/unknown startup); the // driver is best-effort and never blocks the conduit, which keeps serving regardless. const BOOT_DRIVE_MS = 120_000 // MID-SESSION nag-watcher cadence: same 2 s model poll as the boot-driver. After answering a nag, hold // this long before the next check so a just-dismissed modal (claude repaints it away within a frame) is // never re-answered — a stray keystroke would land in the now-visible composer. If the modal genuinely // survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost. const NAG_POLL_MS = 2000 const NAG_COOLDOWN_MS = 4000 /** В60 — how long the pane must be COMPLETELY static (no pty writes) before the nag-watcher may * fire. A genuinely blocked modal freezes the session forever, so a ~10 s delay costs nothing; * a live peer rendering the modal text keeps writing (stream/spinner) and never crosses this. */ const NAG_STUCK_MS = 10_000 // docs/17 fork 3 — bound how many times the daemon re-asks a human about a GENERIC unknown modal whose // injected keys did NOT clear it (a modal ignoring both option-1 and Esc). After this many answered-but- // still-static rounds it logs once and safe-PARKS (the peer stays blocked = safe) instead of looping // forever. The counter resets the moment the pane makes progress (writeSeq advances → the modal cleared). const MAX_UNKNOWN_MODAL_ATTEMPTS = 3 // В25 — cap the attach-snapshot stabilization wait: under saturating output the model never goes quiet, // so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the // current model; live-forward delivers the rest once the gate lifts. const MODEL_STABLE_BUDGET_MS = 500 /** How long firstAttach waits for the child's SIGWINCH redraw wave to ARRIVE after the attach * resize (first bytes; the В25-capped stability drain follows). Signal delivery + a full TUI * redraw take tens of ms; a child that answers SIGWINCH with nothing just costs this timeout. */ const SIGWINCH_REDRAW_WAIT_MS = 300 // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use. interface PtyChild { pid: number /** Resolves with the exit status once the process is REAPED (the pty exit callback fires earlier). */ exited?: Promise exitCode?: number | null signalCode?: string | number | null terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void } kill(sig?: number | string): void } export interface SupervisorDaemonOptions { session: string runtime: string runDir: string /** Child working directory (default: runDir — a throwaway scratch for dark validation). */ cwd?: string /** Override the runtime launcher (else resolved from env override / PATH, else the bare name). */ bin?: string /** SERVING SEAM (slice b): the composed runtime argv (buildLaunchInvocation). When set, the daemon * serves THIS verbatim instead of the bare [bin] — the supervisor is a HOST, not a composer * (launch/lifecycle composes argv+env; the supervisor, like tmux, just serves the invocation). */ argv?: string[] /** SERVING SEAM (slice b): the composed child env (buildLaunchInvocation) — base for the child. * The pty-host env (TERM set, TMUX/TMUX_PANE stripped) is layered ON TOP. Unset → process.env. */ env?: Record /** PANE-LOG (slice c): absolute path the child's RAW output bytes are appended to — `/ * .log`, the SAME file + append semantics tmux `pipe-pane` writes today, so the observer * / shadow / readyGateViewport tail a served session's log identically. Unset → no pane-log * (throwaway/tick). */ paneLogPath?: string /** EXIT-CAUSE (spawn-flip Ф0b resilience-b): exits.log path. On child exit the daemon appends a * `ev=session-exit … dead_status/dead_signal host=supervisor` line (mirroring the tmux pane-died * hook), so the supervise-tick death classification + crash-loop accounting have the cause. */ exitLogPath?: string cols?: number rows?: number } /** Run the supervisor daemon in THIS process (blocks until the child exits / SIGTERM). Spawn it * detached via startSupervisorDaemon (index.ts) for a real session; call directly in a test that * owns its own process. */ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void { const { session, runtime, runDir } = opts let cols = opts.cols ?? 80 let rows = opts.rows ?? 30 // The SERVE geometry — the size the session is hosted at (HOST_COLS×HOST_ROWS for a pty-host peer), // immutable. `cols`/`rows` above track the CURRENT (client-driven) size. A client attaches at its own // terminal size and the pty resizes to match; on LAST detach we restore the serve geometry (below) so // the unattached session never sticks at a departed client's size — otherwise the warm-deliver / // ready-gate viewport model (built at the fixed serve geometry) mismatches the actually-resized pty. const serveCols = cols const serveRows = rows const sock = sockPath(runDir, session) const pid = pidPath(runDir, session) const xterm = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 }) // SerializeAddon turns the resolved model (scrollback + screen + cursor + SGR colour + private modes) // into a byte string that recreates it in a fresh terminal — the attach catch-up. This is how tmux / // screen / zellij restore state on attach (emulate the VT, re-render on attach). Color comes free here // (translateToString was text-only → the monochrome bug); serialize() carries SGR (xterm PR #2369). const serializeAddon = new SerializeAddon() xterm.loadAddon(serializeAddon) const bin = opts.bin ?? knownLauncher(runtime) ?? runtime // SERVING SEAM (slice b): opts.argv (launch-composed, carries the system-prompt swap + PEER_START_ARGS) // is served verbatim; else the bare conduit ([bin], or the deterministic tick stream for tests). const CMD = opts.argv ?? (runtime === 'tick' ? TICK_CMD : [bin]) // pty-host env layered ON TOP of the composed child env (opts.env, = buildLaunchInvocation): force a // real TERM and strip TMUX/TMUX_PANE so a served child never believes it is inside tmux. The identity // ABI (PEER_IDENTITY, IAPEER_BEARER, …) rides in opts.env underneath, untouched. const env = { ...(opts.env ?? process.env), TERM: 'xterm-256color' } as Record delete env.TMUX delete env.TMUX_PANE // PANE-LOG (slice c): append the child's RAW output bytes to /.log, byte-identical // to tmux `pipe-pane … 'cat >> '`. The fd is opened once (append); every chunk is written as a // raw Buffer (writeSync(fd, buf)) — NEVER via a string (Bun re-encodes a latin1 string to UTF-8 and // corrupts multibyte glyphs like ❯/›; the canonical latin1/Buffer grabli). Parent dir 0700, matching // launch's mkdir before pipe-pane. Unset paneLogPath → no log (throwaway/tick). let paneLogFd = -1 let paneLogIno = -1 // inode the fd points at — to detect an out-from-under-us unlink/replace let paneLogCheckedMs = 0 const PANELOG_HEAL_MS = 1000 // throttle the path-existence re-check to ≤1/sec regardless of output volume const openPaneLog = (): void => { if (!opts.paneLogPath) return try { mkdirSync(dirname(opts.paneLogPath), { recursive: true, mode: 0o700 }) if (paneLogFd >= 0) try { closeSync(paneLogFd) } catch { /* */ } paneLogFd = openSync(opts.paneLogPath, 'a') paneLogIno = fstatSync(paneLogFd).ino } catch { paneLogFd = -1 // best-effort: a log open hiccup never blocks serving the session paneLogIno = -1 } } // SELF-HEAL (Defect 2): the pane-log path can be unlinked out from under a live session (observed on // the fleet: long-lived sessions' lifecycle pane-logs vanished while the supervisor ran). The fd then // keeps writing to a now-unlinked inode — the path stays gone and every occupancy reader of // `/.log` goes blind: composer-busy delivery detection AND telegram-runtime's // mtime-based typing/activity indicator (shared file). Throttled re-check before a write: if the path // vanished or now maps to a DIFFERENT inode than the fd, reopen 'a'. capPaneLogs truncates IN PLACE // (same inode) so a normal cap never triggers a spurious reopen. Tied to output (no idle wakeups) — // exactly when the log matters. const healPaneLog = (): void => { if (!opts.paneLogPath) return const now = Date.now() if (now - paneLogCheckedMs < PANELOG_HEAL_MS) return paneLogCheckedMs = now let ino = -1 try { ino = statSync(opts.paneLogPath).ino } catch { ino = -1 // path gone } // В19 — reopen when the fd is DEAD (initial open failed / a prior reopen failed) as well as when the // path maps to a DIFFERENT inode (unlinked/replaced). The old `paneLogFd < 0` early-return above made // a single failed open PERMANENT — the log stayed blind for the whole session (composer-busy detect, // telegram typing, boot ready-gate all read this file). fd<0 with ino=-1 (path also gone) still needs // a reopen attempt: openPaneLog recreates the file. if (paneLogFd < 0 || ino !== paneLogIno) openPaneLog() } openPaneLog() const clients = new Set() const writers = new WeakMap() const framers = new WeakMap void>() // FIRST-PAINT TRACKING (Bug B fix): a client gets the full scrollback catch-up EXACTLY ONCE — on its // first FRAME_RESIZE (which runClient always sends on connect, at the real terminal size). Re-dumping // on a later resize STACKS duplicate histories in a terminal that ignores \x1b[3J (Apple Terminal has // NO E3 capability) → smeared/overlapping scroll-up history. After the catch-up the supervisor does // NOTHING on resize — the child redraws its viewport (SIGWINCH) and the terminal reflows its own // scrollback, like a directly-launched claude. // В23 — a SET (not a WeakSet) so `.size` gates the .attached marker on INTERACTIVE clients only. A // client is painted iff it sent a FRAME_RESIZE (runClient always does on connect; a deliver-client // NEVER resizes). Using clients.size counted deliver-clients too → a delivery briefly marked "human // attached", falsely holding a concurrent delivery in the composer queue. Kept in sync with removals. const painted = new Set() // INITIALIZING GATE (scrollback fix): a client is held OUT of live output-forwarding from connect until // its one-time attach snapshot has been sent. Without this, live frames (claude's relative-cursor // redraws) race ahead of the snapshot and corrupt the fresh terminal. The model still ingests those // bytes, so the snapshot (built FROM the model) already contains them; once the snapshot is sent the // client leaves this set and live forwarding resumes — no gap, no duplication. const initializing = new WeakSet() // MODEL-WRITE PARSE BARRIER: @xterm `write` is async (parsed on an internal queue). The attach snapshot // must be built only AFTER in-flight writes have parsed AND none arrived during the wait, else it reflects // a stale screen / drops bytes that landed while the client was gated. `writeChain` tracks the latest // write's parse-callback; `writeSeq` bumps on every write so the drain can detect new arrivals mid-await. let writeChain: Promise = Promise.resolve() let writeSeq = 0 let lastInputAt = 0 type Fence = { owner: object; token: string; until: number; ready?: ReloadReply; committed?: boolean } let fence: Fence | undefined let fenceSeq = 0 const structuralDir = env.IAPEER_OPENCODE_DIR const releaseFence = (): void => { if (fence?.committed) return const token = fence?.token fence = undefined if (token && runtime === 'opencode' && structuralDir) void opencodeControl(join(structuralDir, 'control.sock'), { op: 'releaseReload', token }, 1000) } // В24 — a trailing INCOMPLETE escape sequence carried from one child-output chunk to the next, so a // capability query split across a pty read boundary is detected/stripped as a whole (client-facing only; // the model + pane-log always get the raw bytes immediately). let queryTail = '' // ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `/.attached` in sync with // clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process) // reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished // composer text), never for the AI's own composer output when no one is attached. Best-effort: a // marker fs hiccup must never break the conduit. const attachedMarker = attachedPath(runDir, session) const syncAttached = (): void => { try { // В23 — gate on PAINTED (interactive) clients, not raw client count: a deliver-client connection // must never read as "a human is attached". if (painted.size > 0) writeFileSync(attachedMarker, '') else if (existsSync(attachedMarker)) unlinkSync(attachedMarker) } catch { /* */ } } // CRASH-ROBUSTNESS: a crashed predecessor daemon skips shutdown-cleanup, so a STALE // `.attached` could survive into this fresh daemon — and a stale-present marker would make the // warm-deliver gate believe a human is attached when none is, holding deliveries forever (the // anti-bug inverted). A fresh daemon has ZERO clients, so clear the marker on START (idempotent). syncAttached() const writerFor = (s: { write(b: Buffer): number }): BackpressureWriter => { let w = writers.get(s as object) if (!w) { w = new BackpressureWriter(b => s.write(b)) writers.set(s as object, w) } return w } /** Backpressure-safe send to one client; drop it (and report false) if its socket is dead. */ const sendClient = (s: { write(b: Buffer): number }, buf: Buffer): boolean => { const ok = writerFor(s).send(buf) if (!ok) { clients.delete(s) painted.delete(s as object) // В23 — keep the painted set (marker gate) in sync on every drop syncAttached() } return ok } // ── death-trace (fleet-API follow-up, 05.07) ────────────────────────────────── // exits.log is the durable «why did this session end» record. Before this, ONLY a // child that died on its own (exit callback below) wrote a line; a SIGTERM'd // supervisor (reap / stop / new / self-fresh — EVERY daemon-initiated teardown) // killed the child and process.exit'ed before the callback could run, and a // supervisor crash left nothing at all — exits.log stayed silent for weeks while // sessions died (live gap: claude-boris server-dead 04.07 23:59 with ZERO forensics; // the supervise classification even points the investigator at exits.log). Now every // in-process exit path writes exactly one line: `exitLogged` dedups the child-exit // callback vs the shutdown path. logfmt via the shared writer (quoted values parse // intact in the fleet SSE stream). Out-of-process deaths (SIGKILL / OOM / Bun panic) // remain unrecordable from here by nature — for those the supervise-tick reaped-gone // classification in lifecycle.log stays the only trace. let exitLogged = false const appendExitEvent = (fields: Record): void => { if (!opts.exitLogPath) return try { mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 }) appendFileSync(opts.exitLogPath, `${formatEventLine(Date.now(), fields)}\n`) } catch { /* best-effort — the trace must never block serving/teardown */ } } // OpenCode (docs/20): the in-process plugin cannot record its own death on a signal (dispose does // not run), so the host records `runtime.stopped` + state=stopped once the child is gone. Idempotent // with a graceful dispose (state already stopped → no second record). Best-effort, never blocks. let structuralStopRecorded = false const recordStructuralStop = (reason: string, exitCode?: number, signal?: number | string): void => { if (structuralStopRecorded || runtime !== 'opencode') return structuralStopRecorded = true const dir = (env as Record).IAPEER_OPENCODE_DIR if (!dir) return try { recordOpencodeStopped( { state: join(dir, 'state.json'), events: join(dir, 'events.jsonl') }, { reason, ...(exitCode !== undefined ? { exitCode } : {}), ...(signal !== undefined ? { signal: String(signal) } : {}) }, ) } catch (e) { appendExitEvent({ ev: 'opencode-stop-record-failed', identity: session, reason: (e as Error).message }) } } const child = Bun.spawn(CMD, { cwd: opts.cwd ?? runDir, env, // Bun-native PTY: a real pty pair, child output arrives via terminal.data(t, bytes). terminal: { cols, rows, name: 'xterm-256color', data(_t: unknown, d: Uint8Array | string) { const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d) // PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping // for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only. if (opts.paneLogPath) { healPaneLog() // reopen if never-opened / unlinked / replaced out from under us (Defect 2 + В19) if (paneLogFd >= 0) { try { writeSync(paneLogFd, buf) } catch { /* best-effort */ } } } writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await) writeChain = new Promise(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier) — RAW bytes // В24 — run capability detect/strip over the chunk PLUS any carried tail, holding back a trailing // INCOMPLETE escape for the next chunk so a query split across a pty read boundary is matched as a // whole. The model + pane-log above get the RAW bytes immediately; only the client-facing detect/ // strip is tail-buffered (the held bytes reach the client next chunk, once complete). const [complete, dangling] = splitDanglingEscape(queryTail + buf.toString('latin1')) queryTail = dangling for (const r of capabilityResponses(complete)) child.terminal.write(r) // answer capability queries const fwd = Buffer.from(stripQueries(complete), 'latin1') // forward to clients sans queries (daemon is authoritative) if (fwd.length) { // Forward live output to every ATTACHED client — but NOT to one still INITIALIZING (its // snapshot hasn't been sent; these bytes are already in the model and will be in that snapshot, // so forwarding now would duplicate / race ahead of the snapshot). for (const c of [...clients]) { if (initializing.has(c as object)) continue sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd)) } } }, exit() { // resilience-b exit-cause (spawn-flip Ф0b): record WHY the hosted child died into exits.log, // mirroring the tmux pane-died hook (ev=session-exit dead_status/dead_signal), so the daemon's // supervise-tick death classification + crash-loop accounting have the cause. host=supervisor // distinguishes the source. // // The Bun-native pty `exit` callback fires on pty EOF, BEFORE the process is reaped, and passes // (0, null) even for a SIGKILL (measured, Bun 1.3.13: callback (0, null); `exited` → 137, // signalCode 'SIGKILL'). Reporting those args recorded a SIGKILL as a clean exit 0 (linus OC07). // So wait for the reap (bounded) and read the subprocess's own exitCode/signalCode. exitLogged = true // this IS the death record — a racing shutdown must not write a second one const finish = (): void => { const status = child.signalCode != null ? undefined : (child.exitCode ?? undefined) const signal = child.signalCode ?? undefined recordStructuralStop('child-exit', status, signal) appendExitEvent({ ev: 'session-exit', identity: session, dead_status: status ?? '', dead_signal: signal ?? '', cause: 'child-exit', host: 'supervisor', }) shutdown() } const reaped = child.exited ?? Promise.resolve(0) void Promise.race([reaped.catch(() => 0), new Promise(r => setTimeout(r, 2000))]).then(finish) }, }, } as unknown as Parameters[1]) as unknown as PtyChild const childToken = pidStartToken(child.pid) xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child /** Catch up a (re)attaching client to the current screen + history ONCE by sending a SNAPSHOT of the * resolved @xterm MODEL — scrollback + viewport as committed plain-text rows — NOT a replay of the * child's raw frames. The model has already flattened claude's relative-cursor in-place redraws into * final lines (no DECSTBM / alt-screen — see the census), so the rows render as native scrollback with * NO duplication. Raw-frame replay can't (relative cursor-up can't reach scrolled-off lines on a linear * dump → overlap) and SerializeAddon re-emits escapes that the no-scroll-region redraws turn into * DUPLICATE scrollback — the two prior dead-ends. Awaits the model parse barrier first (write is async), * then builds + sends the snapshot SYNCHRONOUSLY and lifts the INITIALIZING gate; with no await between * build and lift, a live data() event cannot interleave (single-threaded) → no gap, no double-forward. * Scrollback rows are spinner-stripped; the viewport stays verbatim. The child's SIGWINCH redraw (from * applyGeometry) then settles the live viewport at the client's width. Backpressure-safe * (BackpressureWriter) — a large snapshot MUST NOT drop past the socket buffer (port-dep #2). Called * EXACTLY ONCE per client (its first FRAME_RESIZE). The daemon/model sends NO clears * (\x1b[2J/3J): Apple Terminal scrolls 2J into scrollback + ignores 3J. The attach client owns its * physical viewport and normalizes it once with HOME+ED0 before connecting. */ // Await until the model has parsed ALL writes AND none arrived during the wait (stable). Awaiting a single // write promise is not enough: while the client is gated (initializing, live-forward skipped) new child // bytes still land in the model and bump writeSeq — loop until the sequence holds steady so the snapshot // reflects every byte received so far (no stale screen, no dropped tail). const awaitModelParsedStable = async (): Promise => { // В25 — BOUND the stabilization. Under a saturating output stream (a big file dump, a verbose build, // a tailing log) every await window sees a new write → seq never holds steady → firstAttach never // completes, so the attaching operator sees a FROZEN blank screen while their keystrokes go to the // child blind. Cap the wait: on timeout, snapshot the CURRENT model — bytes that land after are // delivered by normal live-forward once the gate lifts (the model already ingests them). const deadline = Date.now() + MODEL_STABLE_BUDGET_MS for (;;) { const seq = writeSeq try { await writeChain } catch { /* a write parse-callback should not reject */ } if (seq === writeSeq) return if (Date.now() > deadline) return // saturating output — snapshot now, live-forward catches the rest } } // Build + send the one-time attach SNAPSHOT (full resolved model: scrollback + screen + cursor + SGR colour // + private modes) via SerializeAddon, and LIFT the initializing gate — SYNCHRONOUS (no await), so the live // data() handler cannot interleave between serializing and resuming live-forward (single-threaded) → no gap, // no double-forward. Called only from firstAttach, which owns the order (drain → model-resize-to-client → // THIS → child-resize-if-changed). serialize() recreates the EXACT grid incl. cursor, so claude's live bytes // continue seamlessly from the matching state — no separate viewport repaint, no double-paint overlap. The // model is clean because the child is NEVER shrunk on detach (the resize-churn that polluted scrollback is // gone), so serialize faithfully reports clean coloured history. Trailing \x1b[m guards the wrap bg-bleed // artifact (xterm.js #3102). This is the tmux/zellij "re-render the emulated model on attach" architecture. const sendSnapshot = (s: { write(b: Buffer): number }): void => { const snap = serializeAddon.serialize({ scrollback: xterm.buffer.active.baseY }) // all scrollback + screen, with colour const body = snap.length > 0 ? snap + '\x1b[m' : '' if (body) sendClient(s, frame(FRAME_DATA, Buffer.from(body, 'utf8'))) initializing.delete(s as object) // snapshot sent → resume live forwarding } // Resize the model + child pty to (c, r). Shared by LATER client resizes and the last-detach restore. // NO-OP when already at (c,r): a gratuitous resize SIGWINCHes the child → claude reflows its WHOLE // transcript, and when that reflow is taller than the viewport the overflow scrolls into the model's // scrollback as a DUPLICATE copy (the scrollback-pollution root). One real resize = one SIGWINCH; the // old (r-1)→r "nudge" doubled every SIGWINCH and is removed for the same reason. const applyGeometry = (c: number, r: number): void => { if (c === cols && r === rows) return // unchanged → no SIGWINCH → no reflow → no scrollback pollution cols = c rows = r try { xterm.resize(c, r) child.terminal.resize(c, r) } catch { /* transient */ } writeGeometry(runDir, session, c, r) // record current child geometry → sidecar for warm-deliver / ready-gate readers } // FIRST attach for a client. Order for a clean, coloured, correctly-sized snapshot: // 1. DRAIN to stability — pending child bytes were generated under the OLD geometry; parse them BEFORE a // model resize (else they rewrap wrong), and capture bytes that land while the client is gated. // 2. MODEL + CHILD resize to the client geometry IF it differs — BOTH, BEFORE the snapshot. serialize() // round-trips only at matching dims (xterm.js #3093); and the child's SIGWINCH must fire NOW so its // full redraw is INGESTED BY THE MODEL (client still gated) instead of being live-forwarded on top // of an already-sent snapshot. The prior order (child resized AFTER the snapshot) was the attach-dup // root on a BUSY session (live incident, Артур 03.07): the post-snapshot SIGWINCH redraw arrived as // live bytes whose relative cursor-ups no longer matched the client screen — the whole block painted // AGAIN below the snapshot (stacked "Composing…" spinners) — and every busy tick in the window before // the SIGWINCH was still OLD-width, wrapping into extra phantom rows. Reproduced: 2× spinner + 2× // content copies in the client buffer; with this order: 1× each. // 3. WAIT for the redraw wave — the same bounded stabilization as step 1 (В25 cap applies: a saturating // stream snapshots at the deadline; ticks after the snapshot are already client-width, so they land // cleanly via live-forward). // 4. SNAPSHOT — serialize + send + lift gate, SYNCHRONOUS (no await between the last stability check and // the send), so no live byte interleaves. A same-size reattach touches the child ZERO times → pure // snapshot, no re-render (step 2 is a no-op). // Wait for the FIRST child bytes after a resize (the SIGWINCH redraw wave arriving), or the // timeout for a child that answers SIGWINCH with nothing (a plain pipe-like program). A bare // stability check is NOT enough here: signal delivery + redraw generation take milliseconds, // and a busy child's tick cadence has quiet gaps — the stability loop returns in such a gap // BEFORE the redraw arrives (proven by the repro byte log: the \x1b[NA redraw came as frame #2, // after the snapshot), which re-creates the dup exactly. const awaitSeqBump = async (timeoutMs: number): Promise => { const seq = writeSeq const deadline = Date.now() + timeoutMs while (writeSeq === seq && Date.now() < deadline) await Bun.sleep(15) return writeSeq !== seq // true = the child actually emitted its SIGWINCH redraw (vs a silent child) } const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise => { await awaitModelParsedStable() // ORPHAN-SCROLLBACK on a shrinking attach (Артур welcome-attach corruption, 04.07 — live repro): // the model is hosted at HOST 220x50; a real terminal attaches NARROWER (e.g. 120x30). applyGeometry // REFLOWS the model (220→120): every wide line rewraps TALLER, and with fewer rows the TOP of the // pre-resize viewport SPILLS into scrollback. The child's SIGWINCH redraw repaints only the VIEWPORT // (a relative-cursor TUI can't reach scrollback), so the spilled rows linger as stale, reflow-wrapped // orphans ABOVE the clean repaint — the "two frames overlaid + left-clipped" screenshot. This is a // DIFFERENT artifact from the 0.4.59 viewport-dup (that stacked inside the viewport; this is scrollback). const preBaseY = xterm.buffer.active.baseY // committed scrollback history depth BEFORE the resize if (c !== cols || r !== rows) { applyGeometry(c, r) // model + child together — one honest SIGWINCH, BEFORE the snapshot const redrew = await awaitSeqBump(SIGWINCH_REDRAW_WAIT_MS) // the redraw wave ARRIVES (or the child has none) await awaitModelParsedStable() // …then drain it to stability (bounded, В25 cap) // Erase the orphan scrollback ONLY when it is PROVABLY 100% orphan: preBaseY===0 means there was NO // committed history — the ENTIRE pre-resize screen was the child's dynamic tree, which the redraw has // now fully repainted into the viewport, so every post-resize scrollback row is a redundant spilled // copy. Erase it server-side (ESC[3J on the MODEL → serialize then carries only the clean viewport; // no clear reaches the client's native scrollback). GUARDED on an actual redraw (a silent child keeps // its only copy) and on new scrollback existing. preBaseY>0 (real scroll-up history) is left intact: // the history/dynamic-tree seam lives inside the child's renderer and can't be derived here, so we // never risk dropping real history — that narrower case is a separate follow-up. if (redrew && preBaseY === 0 && xterm.buffer.active.baseY > 0) { await new Promise(res => xterm.write('\x1b[3J', () => res())) // eraseScrollback + parse barrier } } sendSnapshot(s) // serialize the post-redraw model → send → lift gate (synchronous, no interleave) } // DETACH = NO RESIZE (emulator invariant). A detached hosted TUI keeps its LAST client geometry — it is // NEVER shrunk back to serve. The old FINDING-2 revert shrank the child into a tiny viewport, making claude // reflow its live region and scroll DUPLICATE copies into scrollback — the root of the reattach-dup bug. // tmux/screen likewise never collapse a detached session's size. Warm-deliver / ready-gate readers follow // the actual geometry via the `.geometry.json` sidecar instead of assuming serve. Kept as a no-op // (callers in the close/drain/error handlers stay unchanged). const restoreServeGeometryIfIdle = (): void => { /* intentional no-op: detach must NOT resize the child — see comment above */ } const onClientFrame = (s: { write(b: Buffer): number; end(): void }, type: number, payload: Buffer): void => { if (fence && !fence.committed && fence.until < Date.now()) releaseFence() if (type === FRAME_RELOAD) { const reply = (r: ReloadReply) => s.write(frame(FRAME_RELOAD, JSON.stringify(r))) if (payload.toString() === 'acquire') { if (fence || clients.size !== 1) { reply({ ok: false, pending: true, error: 'input/attach is active; waiting for an unattached turn boundary' }); return } const lease: Fence = { owner: s as object, token: `${process.pid}:${++fenceSeq}`, until: Date.now() + 4000 } fence = lease const evaluate = async (): Promise => { if (runtime === 'opencode' && structuralDir) { const r = await opencodeControl(join(structuralDir, 'control.sock'), { op: 'prepareReload', token: lease.token, notBefore: lastInputAt }, 1000) return r.ok && r.sessionID ? { ok: true, resumeRef: r.sessionID } : { ok: false, pending: true, error: r.error ?? 'OpenCode has no idle session to resume' } } return nativeIdle({ runtime, childPid: child.pid, cwd: opts.cwd ?? runDir, env, lastInputAt }) } void evaluate().then(r => { if (fence !== lease) return lease.ready = r reply(r) if (!r.ok) releaseFence() }).catch(e => { reply({ ok: false, error: String(e) }); releaseFence() }) } else if (payload.toString() === 'retire') { if (fence?.owner !== s || !fence.ready?.ok || fence.until < Date.now()) { reply({ ok: false, error: 'reload fence expired' }); return } fence.committed = true reply(fence.ready) // Keep the input fence closed while the response flushes. No client can submit work // between the native idle proof and shutdown. No timer/await reopens a committed fence. setTimeout(() => shutdown('capability-reload'), 25) } return } if (fence && (type === FRAME_DATA || type === FRAME_RESIZE)) { s.write(frame(FRAME_ERROR, 'session is reloading; retry shortly')) s.end() return } if (type === FRAME_DATA) { lastInputAt = Date.now() child.terminal.write(payload) // keystrokes → child s.write(frame(FRAME_INPUT_ACK, '')) } else if (type === FRAME_RESIZE) { const sz = parseSize(payload) if (!painted.has(s as object)) { painted.add(s as object) syncAttached() // В23 — this client is now INTERACTIVE (it resized) → the .attached marker reflects it void firstAttach(s, sz.cols, sz.rows) // split-resize: model → snapshot → gate-lift → child (clean frame0) } else { applyGeometry(sz.cols, sz.rows) // LATER resize: one real resize, no re-snapshot } } } try { if (existsSync(sock)) unlinkSync(sock) } catch { /* */ } writeFileSync(inputProtocolPath(runDir, session), String(process.pid), { mode: 0o600 }) const server = Bun.listen({ unix: sock, socket: { open(s: { write(b: Buffer): number; end(): void }) { framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p))) clients.add(s) initializing.add(s as object) // hold live-forward until this client's snapshot is sent (race guard) syncAttached() // ≥1 client now attached → marker present // NO repaint here. A viewer's runClient sends its real terminal size as the FIRST FRAME_RESIZE on // connect (client.ts open → "repaint at our size"); we paint THEN, ONCE, at the correct geometry. // Painting here at the serve geometry (e.g. 220×50) would send a second, wrong-width dump that // STACKS in a terminal ignoring \x1b[3J (Apple Terminal) → smeared scroll-up history (Bug B). A // deliver client (deliverToHost) never resizes and ignores output → it gets no wasted paint. }, data(s: object, d: Uint8Array) { framers.get(s)?.(d) }, drain(s: object) { if (!writers.get(s)?.flush()) { clients.delete(s); painted.delete(s); syncAttached() } // writable again → flush queued tail }, close(s: object) { if (fence?.owner === s) releaseFence() clients.delete(s) painted.delete(s) writers.delete(s) syncAttached() restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size }, error(s: object) { if (fence?.owner === s) releaseFence() clients.delete(s) painted.delete(s) writers.delete(s) syncAttached() restoreServeGeometryIfIdle() }, }, }) writePidFile(runDir, session, process.pid) // В22 — ` ` so a reused pid can't impersonate this session writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize let down = false function shutdown(cause = 'shutdown'): void { if (down) return down = true // Death-trace: a signalled shutdown kills the child below and process.exit()s // immediately — the child exit callback never runs, so THIS is the only chance // to record the session's end. `exitLogged` is already set when the child died // on its own (the callback wrote the authoritative dead_status/dead_signal line). if (!exitLogged) { exitLogged = true appendExitEvent({ ev: 'session-exit', identity: session, cause, host: 'supervisor' }) } if (paneLogFd >= 0) try { closeSync(paneLogFd) } catch { /* */ } try { // self-fresh/stop/reap owns the PTY's descendants, not just its TUI root. Snapshot BEFORE // killing the runtime; exclude detached/shared daemons even if they are descendants. const cleaned = killPtyDescendants(child.pid, childToken) if (cleaned.killed || cleaned.skipped) appendExitEvent({ ev: 'session-tree', identity: session, ...cleaned }) } catch (e) { appendExitEvent({ ev: 'session-tree', identity: session, error: (e as Error).message }) } try { child.kill('SIGKILL') } catch { /* */ } recordStructuralStop(cause) try { server.stop(true) } catch { /* */ } for (const f of [sock, pid, servePath(runDir, session), attachedMarker, geometryPath(runDir, session), inputProtocolPath(runDir, session)]) { try { unlinkSync(f) } catch { /* */ } } process.exit(0) } process.on('SIGTERM', () => shutdown('shutdown-sigterm')) process.on('SIGINT', () => shutdown('shutdown-sigint')) process.on('SIGHUP', () => { /* daemon survives client window close */ }) // A survivable fault must still leave a trace: the console.error goes to the // detached daemon's session log, which sat at 0 bytes for weeks on the live fleet — // effectively invisible. Record it in exits.log too (ev=supervisor-uncaught; the // session keeps serving — killing it here would regress the detach-crash robustness), // so a later wedge/death postmortem can see the fault that preceded it. const traceFault = (kind: string, e: unknown): void => { try { console.error(`[supervisor-daemon] ${kind}`, (e && (e as Error).stack) || e) } catch { /* */ } appendExitEvent({ ev: 'supervisor-uncaught', identity: session, kind, reason: String((e as Error)?.stack ?? e).replace(/\s+/g, ' ').slice(0, 300), }) } process.on('uncaughtException', e => traceFault('exception', e)) process.on('unhandledRejection', e => traceFault('rejection', e)) // ── boot-dialog driver (serving slice a) ──────────────────────────────────────── // For a real runtime, answer the startup dialogs (codex trust/update/hooks, claude trust/resume) // off the AUTHORITATIVE @xterm model so the session reaches a ready input surface UNDER the // supervisor — exactly what the launch primitive does over `tmux capture-pane` (read) + `send-keys` // (write), but model-read (modelToPlainText) + raw pty-byte-write (keysToBytes). The cursor-key // byte follows the model's live DECCKM mode (xterm.modes.applicationCursorKeysMode) — tmux keys off // the same mode, so hooks-review/resume-picker Downs are byte-identical. DARK: the supervisor // serves nothing on the live fleet; this drives only throwaway sessions until the serving flip. const bootAdapter = BOOT_ADAPTERS[runtime] if (bootAdapter) { const driveDeadline = Date.now() + BOOT_DRIVE_MS const driver = setInterval(() => { if (down) { clearInterval(driver) return } if (fence) return let action try { const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode } action = nextBootAction(bootAdapter, modelToPlainText(xterm, cols, rows), enc) } catch { return // transient model read — retry next tick } // В21 — check the deadline FIRST, independent of the action. Previously the clearInterval only // fired in the `else` branch, so a boot-dialog predicate that kept returning 'dialog' (a resume- // picker whose layout changed after a claude update, a screen matching the dialog pattern) drove // keystrokes into the pty EVERY tick for the whole session — the BOOT_DRIVE_MS backstop was dead. if (Date.now() > driveDeadline) { clearInterval(driver) return } if (action.kind === 'dialog') child.terminal.write(action.bytes) else if (action.kind === 'ready') clearInterval(driver) }, BOOT_POLL_MS) ;(driver as { unref?: () => void }).unref?.() } // ── mid-session nag-watcher (livability) ──────────────────────────────────────── // The boot-driver above STOPS at ready. But claude/codex can pop a ONE-TIME upsell modal // MID-SESSION (e.g. "Try the new fullscreen-renderer?", which appears on the return-to-prompt // boundary after a tool result) that BLOCKS the pty on a keypress no headless peer answers — it // froze live fleet peers (boris, doc) until a human cleared it by hand. A PERSISTENT watcher answers // the curated, verified-safe DECLINE for that class off the SAME authoritative @xterm model, for the // WHOLE session lifetime, exactly like the boot-driver answers startup dialogs — model-read // (modelToPlainText) + raw pty-byte-write (keysToBytes). Cooldown-guarded: after firing, hold // NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in // the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys. const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm || nagAdapter?.unknownBlockingModal) { // ── Unit 4 — approval-mode routing for the circuit-breaker ────────────────────── // Resolve the peer's approval mode ONCE at watcher setup: the profile is stable for the session (a // mid-session flip needs a fresh launch), and re-reading it every tick would hammer the fs. yolo // (default) → the breaker AUTO-presses YES as before (restores the pre-2.1.x bypass status quo, // owner decision). gated → the breaker ROUTES the confirm to the human-approval broker (blocking // POST, inject the human's Yes/No). approvalModeOf is the ONE "absent⇒yolo" default — never // re-derived here; an unreadable/malformed profile → yolo (approvalModeOf(null)), the safe backstop // (a gated peer only launched WITHOUT bypass because the SAME profile read gated moments earlier). let approvalMode: ApprovalMode = 'yolo' try { approvalMode = approvalModeOf(readPeerProfile(opts.cwd ?? runDir)) } catch { /* unreadable / malformed profile → yolo default */ } // Broker identity: prefer the composed child-env ABI (PEER_PERSONALITY/RUNTIME, invocation.ts), else // derive from the session name (`-`) by STRIPPING the runtime prefix — the // personality itself may carry a '-', so never split on the first dash. const brokerEnv = opts.env ?? process.env const brokerPersonality = brokerEnv.PEER_PERSONALITY?.trim() || (session.startsWith(`${runtime}-`) ? session.slice(runtime.length + 1) : session) const brokerRuntime = brokerEnv.PEER_RUNTIME?.trim() || runtime // In-flight guard: the setInterval keeps firing while a broker POST is outstanding (the modal stays up, // the pane stays stuck), so gate to EXACTLY ONE broker request per wedged modal. let brokerInFlight = false let nagFiredMs = 0 // docs/17 fork 3 — bounded re-enqueue state for a GENERIC unknown modal. `attempts` counts answered- // but-still-static rounds; `parked` latches after MAX to stop asking. Both reset on pane progress // (writeSeq advance) so a later, DIFFERENT modal is handled fresh. let unknownModalAttempts = 0 let unknownModalParked = false let lastNagSig: string | null = null // В60/В61 — fire ONLY on a genuinely WEDGED prompt, gated on the detected modal/nag's CONTENT // STABILITY, not raw pty writes: codex REPAINTS its native approval modal every ~2 s, so a writeSeq // stuck-gate never froze and the watcher never fired (live repro eph-cdxdet 08.07: 0 events, peer hung). // The SAME signature persisting for NAG_STUCK_MS is the "wedged" signal; a working/streaming peer changes // it (or shows no modal) and re-arms. Composes with the adapter's structural match. See signatureStable. const stabilityGate = newStabilityGate(Date.now()) const nagWatch = setInterval(() => { if (down) { clearInterval(nagWatch) return } if (fence) return if (Date.now() - nagFiredMs < NAG_COOLDOWN_MS) return // let a just-answered modal repaint away first let action try { const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode } action = nextNagAction(nagAdapter, modelToPlainText(xterm, cols, rows), enc) } catch { return // transient model read — retry next tick } // Debounce on the detected action's CONTENT signature (not writeSeq — codex repaints its modal, so // writeSeq churns while the modal is stable). A NEW/absent signature (a different modal, or the modal // cleared) resets the unknown-modal re-enqueue budget so a later modal starts a fresh budget. const sig = nagSignature(action) if (sig !== lastNagSig) { lastNagSig = sig unknownModalAttempts = 0 unknownModalParked = false } if (!signatureStable(stabilityGate, sig, Date.now(), NAG_STUCK_MS)) return // not stable long enough → not wedged if (action.kind === 'dismiss') { child.terminal.write(action.bytes) nagFiredMs = Date.now() } else if (action.kind === 'approve') { // A blocking confirm/modal a headless peer can't answer → it hangs forever. WHERE it goes depends // on the class + the peer's approval MODE (resolved once above): // - alwaysHuman (org-policy / unknown-modal, docs/17 yolo-robustness) → ALWAYS to the human // broker in EITHER mode. Owner's rule: yolo presses what it KNOWS (max known rights); a modal // ABOVE us (org-policy) or one we could NOT classify (unknown) I must SEE and confirm — never // a blind auto-Yes (an unknown modal Anthropic put up on purpose must not be swallowed). // - known auto-Yes class (dangerous-rm / command-approval): gated → broker, yolo → auto-Yes. const routeToHuman = action.alwaysHuman || approvalMode === 'gated' if (routeToHuman) { if (brokerInFlight) return // one broker request per wedged modal (the tick keeps firing) // BOUNDED re-enqueue for a GENERIC unknown modal whose injected keys may not dismiss it (fork 3): // after MAX_UNKNOWN_MODAL_ATTEMPTS answered rounds that leave the pane STILL static, stop asking — // log once and safe-PARK (the peer stays blocked = safe; never an infinite ask loop nor a blind // Yes). Counters reset on pane progress (loop top). Known classes (org-policy) have precise keys // that DO dismiss, so this bound applies only to the heuristic unknown-modal path. if (action.brokerKind === 'unknown-modal') { if (unknownModalParked) return if (unknownModalAttempts >= MAX_UNKNOWN_MODAL_ATTEMPTS) { unknownModalParked = true try { console.warn( `[supervisor] UNKNOWN-MODAL PARKED identity=${session} ts=${new Date().toISOString()} attempts=${unknownModalAttempts} — injected keys did not clear it; manual attention needed`, ) } catch { /* logging must never break the conduit */ } return } unknownModalAttempts++ } // Route to the human-approval broker (docs/17). A blocking POST awaits a human's allow/deny (≤ // broker 300s); routeCircuitBreaker injects the affirmative on allow, the class-specific decline // on deny (dangerous-rm=2.No / command-approval|org-policy=3.No / unknown-modal=Esc), and fails // safe to DENY on any broker fault. For unknown-modal the broker content carries EXPLICIT button // semantics (Allow presses option 1 "