diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 8753fea8..316c7fc3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1,6 +1,6 @@ # Architecture invariants -Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain _why_ each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. +Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. `CLAUDE.md` keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint. @@ -54,8 +54,6 @@ Model is NOT a session field: it is a composition entry in the profile's config ### Remote SSH cases -**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. - **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id || claude --resume ` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: ` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`. ### Docker cases @@ -106,7 +104,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do ### Session lineage lines (tab → tab it spawned) -**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user _human_ owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. +**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. **Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart. @@ -116,6 +114,20 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do ⚠️ **`data-agent-id="lineage:"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths). +### Auto-named sessions (first prompt → tab title) + +**Shipped opt-in, in the prefix form, after a review round that found five ways the first cut named a tab wrong** (#376, 1.30.0). The contributed version renamed on EVERY prompt (`applyAutoName` never left the eligible state, so "fix the login bug" then "1" left the tab named **1**), fed its tracker from every write path (shell tabs renamed after each command, every Ralph and respawn tab named "Read @ralph_prompt.md and follow the instructions."), stayed in escape mode after a bare Esc until a byte in `0x40-0x7e` arrived (Esc then "fix the login bug" submitted **ix the login bug**, Esc then a CJK prompt submitted nothing and the following prompt lost its first character, Esc then digits grew the escape buffer to 19001 characters), treated the newlines inside a bracketed paste as Enter, cleared the draft on ANY CSI (including the SGR wheel reports Codeman forwards to claude ≥ 2.1.187, so "fix the " + wheel + "login bug" gave **login bug**) and on Tab (the `@` completer), and replaced the whole name, which dropped the case from the tab and reset `_nextCaseSessionStartNumber()` so every new session in the case became `w1-` again. Each of those is a named rule in `session-auto-name.ts` with a test. + +**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w-` / `s-`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. + +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` line feed never passes through the session. + +**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. + +**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in. + +**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt. + ### Full-scrollback replay **Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[;H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to `session.terminalBuffer`, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (`hasVisibleContent`) returns `''`, because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where `_replayWouldShrinkBuffer` cannot see it. ⚠️ **"One line per screen row" holds only where no row was hard-wrapped**: `-J` joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption `_estimateReplayRows` already documents. Tests: `test/tmux-capture-full-history.test.ts` covers the cursor move, the trim pairing and `hasVisibleContent`; `test/routes/session-routes.test.ts` covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. @@ -166,7 +178,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `