Compare commits

..
Author SHA1 Message Date
Codeman maintainer 8dc8b164a7 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:48:12 +02:00
Ark0N 2524759655 Merge pull request #229 from Lint111/feat/keyboard-viewport-settle
fix(mobile): coalesce keyboard viewport settling
2026-08-08 12:51:04 +02:00
Codeman maintainer 1f164bc8d2 fix(mobile): only arm the viewport settle on a real keyboard transition
A visualViewport resize event without a pending show/hide transition now
only pushes a pending settle back (_deferViewportSettle) instead of arming
fit + PTY-resize work of its own. Keyboard detection can miss a
fine-grained OS animation entirely (each step under 150px, with the
baseline chasing the animation down), while MobileDetection's own listener
still shrinks --app-height, so the per-event settle fitted xterm against a
mid-animation container with no keyboard CSS compensation and resized the
PTY to transient dims. The resulting SIGWINCH thrash (58 -> 10 -> 50 rows)
duplicated prompts and left tmux dot filler in the transcript on keyboard
close. Reproduced with a faked visualViewport driving the real handler;
master is unaffected because it never resized the PTY from this path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:06:08 +02:00
lior 0a1439b1e9 test(mobile): make the coalescing test actually exercise the settle path
The suite never selects a session, so initTerminal() does not run and both
`app.terminal` and `app.fitAddon` are null at rest. `_scheduleViewportSettle`
returns early on a falsy terminal, so the coalescing assertions could not
reach the behavior they claimed to cover -- the test errored on
`Cannot read properties of null` rather than measuring anything.

Installs the minimum surface the settle callback touches and restores it
afterwards, so the coalescing path executes for real.

Adds a behavioral counterpart driven through the PUBLIC entry point
(`onKeyboardShow`) instead of the internal scheduler: three viewport steps
in quick succession must produce exactly ONE refit. On master that returns
3 (each show arms its own uncoalesced 150ms timeout), so this fails by
COUNT rather than by a missing method -- which is the failure mode that
actually demonstrates the bug.

Verified: `expected 3 to be 1` on unmodified master; passes here. The
remaining 8 failures in this file are pre-existing on master and unrelated
(same null-initialization limitation of the headless harness).
2026-08-08 08:41:04 +03:00
lior 66abe6c70a fix(mobile): coalesce keyboard viewport settling 2026-08-08 08:13:58 +03:00
Codeman maintainer fa1700da5b chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:38:35 +02:00
Ark0N aed1e59ee3 Merge pull request #227 from Ark0N/fix/scrollback-205-round2
fix(terminal): scrollback round 2 for #205 (re-pull downgrade guard, PageUp fallback, CLI version probe retry)
2026-08-08 01:36:03 +02:00
Ark0N 7f6d18b398 Merge pull request #226 from christianhaberl/fix/input-loss-on-failed-delivery
fix(api,ws): an input whose delivery fails can be retried instead of being lost
2026-08-08 01:31:10 +02:00
Ark0N 52571c7fd4 Merge pull request #225 from christianhaberl/fix/bound-the-process-tree-walk
fix(mux): bound the process-tree walk — unbounded pgrep recursion can take a machine down
2026-08-08 01:31:00 +02:00
Ark0N cb95a8562c Merge pull request #224 from christianhaberl/fix/raw-writehead-drops-security-headers
fix(http): raw writeHead routes drop every header the security hook set
2026-08-08 01:30:47 +02:00
Codeman maintainer 3cb7e30636 fix(ui): scope the wheel-opt-out tooltip's paging fallback to Claude
The reworded tooltip promised the PageUp/PageDown fallback for Claude and
Codex alike, but _localScrollbackIsHollow() gates it to claude mode only
(codex page-key handling is unverified, as the routing tests note). A codex
user reading the old text would flip the setting expecting a rescue and get
a dead wheel instead. Say plainly that Codex has no fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:29:48 +02:00
Codeman maintainer 9dc4620f03 fix(terminal): stop the scroll-to-top re-pull from deleting history, page the CLI when local scrollback is hollow (#205)
The 1.12.0 retest on #205 reported it still broken in two shapes: a wheel that
did nothing at all on Firefox/macOS (while Fn+Up paged back through intact
text), and iPhone history that went back a little, repeated blocks and got
worse the further up it went. Both come from a Claude pane's LOCAL buffer being
hollow: tmux keeps no history for a repaint-mode pane (history_size 0), so
xterm holds only replayed repaint frames.

1. The scroll-to-top full=1 re-pull now refuses a DOWNGRADE. It resets the
   terminal and rewrites it from the capture, which is a win when tmux holds
   more than the browser, but for a repaint-mode pane that capture is roughly
   ONE frame and the rewrite deleted history mid-scroll. Measured A/B on a live
   pane, same gesture: guard off collapses 341 rows to 42, guard on preserves
   all 341. _replayWouldShrinkBuffer() estimates the capture's rendered rows
   (escapes stripped, capture-pane -J re-wrapping accounted for) and skips the
   rewrite when it is more than one screen short; a refused session's cooldown
   goes from 4s to 60s so a hollow pane stops re-fetching megabytes.

2. A false forwarding gate on a Claude session no longer means a dead gesture.
   Under a triple guard (claude mode, gate false, baseY 0), wheel and touch
   travel becomes coalesced PageUp/PageDown through the same 40ms queue as the
   SGR reports, at half a screen of travel per page key. Shift is excluded: it
   keeps meaning "local scrollback".

3. getClaudeCliVersion() no longer caches FAILURE. It stored null on any
   exception and guarded on !== undefined, so one timed-out or PATH-starved
   probe at the first Claude session start disabled wheel-forwarding for every
   Claude session until the server restarted, which fits a report of breakage on
   phone, tablet and laptop at once. Success is still cached for the process
   lifetime; failures retry with a 1/2/4 up to 15min backoff, and the policy is
   a pure function so the semantics are testable without spawning claude.

4. The terminalWheelLocalScrollback footgun is handled by pairing rather than
   scoping: the setting keeps meaning exactly what it says, and fix 2 catches
   the case where "local" is empty. The App Settings tooltip now says to leave
   it off for Claude/Codex sessions.

5. _logScrollRouting() prints one line per session per distinct decision:
   forward-sgr / page-keys / local-scrollback / repull-refused-downgrade, with
   mode, cliVersion, the opt-out state, mouse tracking and local scrollback
   depth. #205 ran two rounds of remote guesswork over questions that line
   answers directly.

Verified end to end against a real isolated instance (own data dir and tmux
socket) with real wheel events: forwarding still sends SGR reports, the opt-out
now sends real PageUp/PageDown where the wheel was dead, a tab-switch collapse
(401 rows to 44) is still fully recovered by the re-pull (back to 401), and a
seeded 341-row Claude buffer survives the same gesture that destroys it with the
guard disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:41:39 +02:00
Claudia 9d27cc0bab docs: merge the stacked doc comments the previous commits left behind
Cosmetic, but the kind that quietly costs: JSDoc tooling attaches only the
nearest block, so a stacked second block silently hides the first.

- write() had two: the original description with @param and @example, then a
  @returns-only block added on top, which dropped the params and examples from
  hover. Merged into one. The @returns wording is also honest now — write() still
  discards the data without a PTY; what changed is that it says so.
- forgetInputSeq had been inserted BETWEEN shouldApplyInput's detailed doc comment
  and its declaration, leaving that function undocumented on hover and the doc
  attached to the wrong thing. Moved below.
- The mock kept an orphaned one-line comment above failWrites' own block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:29:46 +02:00
Claudia 84132d3025 fix(ws): pin the withheld ACK with a test, and correct the changeset
Two blockers from the pre-submission gate, both reproduced before fixing.

1. The changeset claimed the non-mux POST branch answers OPERATION_FAILED. The
   code says the opposite in as many words ("NOT an error response,
   deliberately"), the commit message says response codes are unchanged, and the
   test asserts the 200. It was a leftover sentence from an earlier iteration that
   would have shipped into the CHANGELOG announcing an API contract change that
   does not exist — and errorCode values are SemVer-relevant per
   docs/versioning-policy.md.

2. The WebSocket half of the fix had no test protection: reverting ws-routes.ts to
   master left all 9 tests green, while the commit message sells "plus the whole
   WebSocket path" as part of the fix. Three tests added against the real WS
   route — ACK on delivery, ACK withheld and seq re-opened when the write did not
   land, and a deduplicated frame still ACKed so the client can drop it. Verified
   the other way round: with ws-routes.ts reverted, the middle one fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:52:44 +02:00
Claudia ebfcac6ad1 fix(api,ws): an input whose delivery fails can be retried instead of being lost
Both input paths recorded the (clientId, seq) pair as applied and acknowledged the
frame BEFORE knowing whether the write had landed: the POST route because its mux
write is fire-and-forget so the response never waits on a tmux child, the
WebSocket handler because it ACKed unconditionally.

When the write then failed, the client dropped the frame from its durable queue
and the server rejected the retry as a duplicate. The reliable-delivery layer was
guaranteeing exactly-once delivery of something that had never been delivered —
and `Session.write()` returned void, so a session whose PTY was gone swallowed the
data with no signal at all.

- `forgetInputSeq()` rolls the bookkeeping back on failure, but only when that seq
  is still the newest one; a later input has superseded it and must not re-open.
- The WebSocket handler withholds its ACK when the write did not land, so the
  client redelivers.
- `Session.write()` reports whether it reached a PTY.

Response codes are unchanged, deliberately: a session can legitimately have no PTY
yet, and turning that into a failure status would be a contract change of its own.

What this does NOT do: remove the root cause. The POST still answers 200 before
the mux write is attempted, so a client that treats any 2xx as final cannot learn
about that failure. What closes is the narrower window — the write failed AND the
ACK never reached the client — plus the whole WebSocket path. Closing the rest
would mean awaiting the tmux child inside the request.

9 tests. They drive the HTTP route, not only the Session primitives: with the
rollback removed from the route, 2 of them fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:36:33 +02:00
Claudia 2e69e28e71 fix(mux): bound the process-tree walk — it can take a machine down
`getChildPids` ran `pgrep -P <pid>` per node and recursed with no visited set, no
depth limit and no node cap. Two further sites forked a `pgrep` per session on
every stats tick.

Across ~28 adopted tmux trees the fan-out exploded, and because each `pgrep`
blocks in the kernel while reading `/proc/<pid>/cgroup` under WSL, none returned
while the walk kept spawning more. Observed: ~13,000 `pgrep` processes stuck in
D-state out of ~39,000 total, load average above 13,000, and a machine only
recoverable by restarting WSL — which cost every running session. Every diagnostic
command timed out too, because they read /proc as well.

- ONE `ps -eo pid=,ppid=` snapshot, cached briefly and refreshed asynchronously
  with a single-flight guard. Async matters: under the same procfs pathology,
  `execSync`'s timeout cannot return (spawnSync waits for the unkillable child),
  which would freeze the server where a hung async poll only costs staleness.
- The traversal moved to `proc-tree.ts` as a pure function — breadth-first, with a
  visited set (a stale snapshot can contain a cycle), a depth cap and a node cap,
  both reporting when they truncate. Pure so the regression tests can exercise the
  shipped code rather than a copy of it.
- The kill path forces a fresh snapshot: the wait between SIGTERM and the survivor
  re-scan (200ms) sits inside the cache TTL (2000ms), so reading the cache there
  would return pre-SIGTERM state and aim SIGKILL at stale PIDs. That wait is
  bounded, so a wedged `ps` cannot stop killSession from reaching its
  process-group and tmux fallbacks.
- Any `ps` error keeps the previous snapshot instead of caching partial output as
  fresh; a truncated table would make whole subtrees invisible to the kill path.

13 tests, including one that drives TmuxManager itself — with the caps bypassed at
the call site, 3 of them fail. The snapshot refresh is stubbed there, because
otherwise the manager runs a real `ps`, replaces the fixture, and the test
silently measures the machine's own process tree instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:34:47 +02:00
Claudia 1a32e63765 fix(http): raw writeHead routes lost every header the security hook set
`reply.raw.writeHead()` writes straight to the Node response and bypasses
Fastify's header store, so everything the `onRequest` security hook granted is
silently dropped on every route that answers that way.

The visible symptom is CORS. The hook emits `Access-Control-Allow-Origin` for
localhost origins, so a page served from a local dev server may call every `/api`
endpoint cross-origin — except the four below, whose requests fail. The security
headers (`X-Content-Type-Options`, `X-Frame-Options`, CSP) were being lost the
same way.

Affected: `GET /api/events`, and `file-raw` / `tail-file` / `download` in
file-routes.ts. Each now spreads the inherited headers first and lets its own
headers win over them.

Tests drive a real WebServer and compare `/api/events` against `/api/status` for
the same Origin — the point of the fix being that the SSE route stops being the
odd one out. Verified in both directions: with the fix removed, 3 of the 5 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:31:50 +02:00
27 changed files with 2230 additions and 139 deletions

No files matched your search

+69
View File
@@ -1,5 +1,74 @@
# aicodeman
## 1.12.2
### Patch Changes
- Codex input fixes: all four bugs reported by @DodgyBadger traced to one root cause (the zero-lag local-echo overlay buffering keystrokes until Enter, which starves codex's per-keystroke composer) and fixed in terminal-ui.js:
- Slash command picker never appeared in codex sessions (#222): the "/" sat in the overlay until Enter, so codex never saw it. Codex-mode sessions now use plain PTY echo (same branch as shell), so the picker pops and live-filters as you type.
- Arrow keys dead while typing, backspace dead after Ctrl+Backspace (#218): arrows were forwarded to a still-empty composer while typed text sat pending, and after a control-char flush the overlay swallowed every backspace. Codex bypasses the overlay entirely now; the shared overlay branch (claude/gemini/opencode) additionally flushes pending text on composer nav keys, then hands the session to pass-through until Enter/Ctrl+C, and forwards backspace instead of swallowing it when the overlay has no state.
- Pasting displaced the typed prompt (#219): bracketed pastes (xterm terminal.paste with DECSET 2004 active) were forwarded without flushing pending typed text, so the paste landed first. The shared branch now flushes typed text first and delays the paste sequence by 80ms, because codex's paste-burst handling drops keystrokes that arrive in the same PTY read as a bracketed paste (verified against codex 0.147.0 at the byte level).
- Long prompts overflowed the bottom of the screen (#220): long typed prompts existed only in the overlay DOM so codex never grew its composer; with plain PTY echo the composer grows and rewraps normally.
Verified end to end against a real codex 0.147.0 TUI driven by a headless browser: the pre-fix build reproduces all four bugs, the fixed build passes 17/17 assertions. New CI test file test/local-echo-codex-gating.test.ts (41 tests) pins the nav-key classifier, per-mode overlay gating, the flush helper, and pass-through routing. Known upstream limitation: Ctrl+Backspace deletes one character, not a word (xterm.js sends 0x08; word-delete needs kitty CSI-u encoding that xterm.js 6.0.0 cannot emit).
Mobile keyboard viewport settling fixes by @Lint111 (#229): coalesce keyboard viewport settling so rapid visualViewport resize events during keyboard show/hide no longer thrash the terminal fit, and only arm the settle logic on a real keyboard transition instead of every viewport resize.
## 1.12.1
### Patch Changes
- Terminal scrollback fixes, round 2 of issue #205. A Claude pane's local buffer is hollow (tmux keeps no history for a repaint-mode pane), and both retest reports traced back to that fact. The scroll-to-top full-history re-pull now refuses to rewrite the terminal when the capture holds less than the browser already does, so it can no longer delete history mid-scroll on iPhone (a refused session also re-fetches far less often). When wheel-forwarding is unavailable on a Claude session (version probe failed, CLI older than 2.1.187, or the "Wheel Scrolls Local History" opt-out) and there is no local scrollback to scroll, wheel and touch now page the CLI's own transcript via coalesced PageUp/PageDown instead of doing nothing. The `claude --version` probe no longer caches a failed run for the server's lifetime (one timed-out probe used to silently disable wheel-forwarding on every device until restart); failures retry with backoff. Every scroll gesture now logs a one-line `[scroll]` routing decision to the browser console for direct diagnosis, and the opt-out setting's tooltip explains that the paging fallback is Claude-only (Codex has none).
- 2e69e28: Bound the process-tree walk that could take a machine down.
`getChildPids` ran `pgrep -P <pid>` per node and recursed with no visited set, no
depth limit and no node cap. Across ~28 adopted tmux trees the fan-out exploded,
and because each `pgrep` blocks in the kernel while reading `/proc/<pid>/cgroup`
under WSL, none returned while the walk kept spawning more — ~13,000 `pgrep`
processes stuck in D-state out of ~39,000 total, load average above 13,000,
recoverable only by restarting WSL.
Now: one `ps` snapshot, breadth-first with a visited set, a depth cap and a node
cap, in a pure module (`proc-tree.ts`) that the regression tests exercise
directly. The snapshot is refreshed asynchronously, and the kill path forces a
fresh one so the SIGKILL escalation cannot re-read pre-SIGTERM state.
- ebfcac6: An input whose delivery fails can be retried instead of being lost for good.
Both input paths recorded the `(clientId, seq)` pair as applied and acknowledged
the frame _before_ knowing whether the write had landed — the POST route because
its mux write is fire-and-forget, the WebSocket handler because it ACKed
unconditionally. When the write then failed, the client dropped the frame from its
durable queue and the server rejected the retry as a duplicate: the reliable
delivery layer was guaranteeing exactly-once delivery of something that had never
been delivered.
The bookkeeping is now rolled back on failure and the WebSocket ACK withheld, so
the client redelivers. `Session.write()` reports whether it reached a PTY at all
instead of silently swallowing the data.
Response codes are unchanged: a session can legitimately have no PTY yet (created
but not started), so turning that into a failure status would be a contract change
of its own.
Note this does not remove the root cause: the POST still answers 200 before the
mux write is attempted, so a client that treats any 2xx as final still cannot
learn about that failure. Closing that would mean awaiting the tmux child in the
request path.
- 1a32e63: Routes that answer with `reply.raw.writeHead()` no longer drop the headers the
security hook set.
`writeHead` writes straight to the Node response and bypasses Fastify's header
store, so everything the `onRequest` hook granted was silently lost — including the
`Access-Control-Allow-Origin` it emits for localhost origins, and the
`X-Content-Type-Options` / `X-Frame-Options` / CSP headers. A localhost page could
therefore call every other `/api` endpoint cross-origin while its EventSource
failed CORS.
Affects `GET /api/events` and the three raw-writing routes in `file-routes.ts`
(`file-raw`, `tail-file`, `download`).
## 1.12.0
### Minor Changes
+4 -4
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.12.0 (must match `package.json`)
**Version**: 1.12.2 (must match `package.json`)
## Project Overview
@@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **The local-echo overlay is DISABLED for codex sessions** (`_updateLocalEchoState` in terminal-ui.js, same branch as shell): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222. Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
@@ -206,9 +206,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
+7 -1
View File
@@ -22,6 +22,8 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All four modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM).
**Codex input path (issues #218/#219/#220/#222)**: the local-echo overlay is **DISABLED for codex-mode sessions** (`_updateLocalEchoState` in terminal-ui.js, same branch as shell). Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key classifier truth table, per-mode gating, flush helper, pass-through routing).
### Remote sessions over SSH
**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON); the pure pieces (backoff schedule, per-session reconnect state, `decideReconnect` eligibility) live in `src/remote-reconnect.ts` (tests: `test/remote-auto-reconnect.test.ts`), while `tmux-manager.ts` owns the live pane probe + timers. Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`.
@@ -58,7 +60,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### 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 -<lines>` 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 SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). 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. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`.
**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 -<lines>` 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 SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). 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 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`.
### Terminal scrollback: strip flavors and wheel/touch forwarding
@@ -66,6 +68,10 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts.
### Run launch synchronization
+138
View File
@@ -25,6 +25,144 @@ What shipped vs. what this doc proposed:
- **Finding 5 (remote probe)**: implemented (`probeRemoteCliVersion` over ssh, deferred at
session start, same login-shell wrapper as the launch).
## RETEST FAILED (2026-08-07, after v1.12.0 shipped) — analysis round 2
mtiller retested on 1.12.0 and reports it is NOT fixed (issue #205 comment, 2026-08-07 12:12 UTC;
issue reopened same day with clarifying questions: mouse vs trackpad, Shift+scroll behavior,
Claude vs shell session on the phone, and an iOS full-tab-kill to rule out stale JS). Two
failure signatures, now analyzed against the SHIPPED 1.12.0 code (not the pre-fix code):
1. **iPhone Safari (Claude session assumed)**: touch scrollback goes back only a limited
amount and sometimes REPEATS blocks of text; unreliable.
2. **Firefox on macOS (mouse)**: wheel does NOTHING at all, while Fn+Up (= PageUp) pages back
through INTACT text.
### Ruled out by code reading
- deltaMode mishandling: `_wheelScrollLinesFloat` normalizes line/page/pixel units correctly;
a Firefox line-mode notch yields ±3 lines. Not the bug.
- Ephemeral transport: `_sendInputEphemeral` (app.js) has a POST fallback when WS is down.
- Service worker: sw.js is network-first with cache fallback; it serves stale JS only when the
fetch FAILS (flaky mobile connection can do this — relevant to "unreliable" on the phone,
and the fixed `CACHE_NAME = 'codeman-v1'` never invalidates that offline copy).
### The load-bearing observation: PageUp works, the wheel does not
Fn+Up is a KEYBOARD event: xterm encodes PageUp and Claude pages its own transcript (intact
text proves Claude-side history is fine and the PTY input path is fine). The wheel path is the
capture-phase handler, and for a Claude session it has exactly two branches:
- **Forwarding branch** (`_shouldForwardWheelToApp` true): snap-to-bottom + SGR reports. If
this branch ran, the user would see the same paging motion Fn+Up produces. They see nothing.
- **Local branch** (gate false): `_smoothScrollBy` over xterm's local buffer. For a Claude
pane in repaint mode, tmux keeps `history_size≈0`, so `?full=1` returns roughly one frame:
the local buffer is structurally HOLLOW, the top-of-buffer re-pull recovers nothing, and the
wheel looks completely dead. **This matches every observed detail on Firefox.**
So the working hypothesis is that mtiller's sessions evaluate the gate FALSE. The gate
(`_shouldForwardWheelToApp`) has exactly four false-paths worth checking, in likelihood order:
1. **`terminalWheelLocalScrollback` opt-out is ON.** Plausible: a user whose scrolling was
broken on 1.11.x may well have toggled "Wheel scrolls local history" while trying to fix
it. On 1.12.0 that setting now routes the wheel to a hollow local buffer = dead wheel on
desktop AND the stale-repaint-frames experience on the phone (see below). Ask, or check
what the setting does on their export.
2. **`cliVersion` missing — CONFIRMED BUG, independent of whether it is mtiller's**:
`getClaudeCliVersion()` (utils/claude-cli-resolver.ts:124-148) caches its result
process-wide including FAILURE: on any exception it sets `_claudeVersion = null`, and the
guard is `!== undefined`, so a single failed/timed-out probe (5s `EXEC_TIMEOUT_MS`; PATH
under systemd/launchd; transient fs hiccup) at the FIRST Claude session start disables
wheel forwarding for every Claude session until the server restarts. Fix: cache success
permanently, but let failure retry (retry on next call, or a short negative-cache TTL).
Note that mtiller sees identical breakage on phone + iPad + laptop, which points at a
SERVER-side/session-side cause exactly like this (cliVersion is shared by all devices)
rather than anything browser-specific.
3. **Claude Code genuinely < 2.1.187** on their machine: gate false BY DESIGN, but the
resulting UX is a dead-end (no local history to fall back on).
4. mouseTrackingMode non-none (a DECSET leaked past the strip, e.g. emitted before attach or
split across chunks in a way the carry missed): would also kill the container handler via
the early return. Least likely, checkable via `terminal.modes.mouseTrackingMode` in console.
### The iPhone symptoms fit the same gate-false story
Touch with gate false = local `scrollLines()` over whatever repaint frames accumulated:
"repeats blocks of text" is literally what a buffer of successive overlapping repaint frames
looks like; "limited amount" is its thinness; "unreliable" is burst-dependence (finding 2)
PLUS the new re-pull being actively DESTRUCTIVE for repaint panes: `_maybeRefetchFullHistory`
does `_resetTerminalForReplay()` then writes the fetched capture, and when that capture is
one frame (Claude pane, `history_size≈0`) it REPLACES a multi-frame buffer with less than the
user had, mid-scroll. Stale pre-1.12 JS on the phone (suspended Safari tab) remains possible
until they confirm the tab kill.
### Fix directions, ranked
1. **Make the re-pull refuse downgrades** (`_maybeRefetchFullHistory`, app.js): if the fetched
capture would yield FEWER buffer rows than currently present, skip the reset+rewrite and
keep the richer buffer (optionally cache-mark the session "re-pull useless"). Small, safe,
kills the "got worse after scrolling to top" class. Consider skipping the re-pull entirely
for forwarding-capable modes where tmux keeps no history.
2. **Rescue the gate-false Claude dead-end with PageUp forwarding**: when mode is `claude`,
the gate is false, AND the local buffer has no scrollback (`baseY === 0`), translate wheel
lines into coalesced PageUp/PageDown key sends (mtiller just proved Claude pages correctly
on PageUp even on their version). Zero regression risk under that triple guard: sessions
with real local history keep local scrolling; only the currently-dead path changes.
Caveat: older Claude menus may react to PageUp; acceptable against "completely dead".
3. **Audit `getClaudeCliVersion()` failure caching** (utils/claude-cli-resolver.ts): a cached
empty probe must retry (with backoff), not poison the process.
4. **Guard the opt-out setting's footgun**: if `terminalWheelLocalScrollback` is ON for a
repaint-mode CLI session, local history is hollow; either scope the setting's effect to
modes with real local scrollback, or pair it with fix 2's PageUp fallback so it still
scrolls SOMETHING.
5. **Add a one-line gate diagnostic**: log (once per session, console) WHY the wheel chose
local vs forward: `{mode, cliVersion, optOut, trackingMode}`. The #205 thread is now two
rounds deep on guesswork a single console line would have answered.
### What shipped for round 2 (branch `fix/scrollback-205-round2`)
All five directions above, implemented as ranked:
1. **Downgrade guard** — `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the rows a
capture will occupy (ANSI stripped, `capture-pane -J` re-wrapping accounted for) and
`_maybeRefetchFullHistory` (app.js) skips the reset+rewrite when that is more than one
screen short of what xterm already holds. A refused session goes on
`_fullHistoryRepullUseless`, which raises its re-pull cooldown from 4s to 60s so a hollow
pane stops re-fetching. Measured A/B on a live Claude pane, same gesture, same buffer:
guard off → 341 rows collapse to 42 and every seeded row is gone; guard on → 341 rows
preserved. The tab-switch recovery it must not break still runs (shell buffer 401 → 44 on
a tab switch → 401 again after scrolling to the top).
2. **PageUp/PageDown fallback** — `_maybePageCliTranscript()` translates wheel/touch travel
into coalesced `\x1b[5~` / `\x1b[6~` under the triple guard (claude mode, forwarding gate
false, `baseY === 0`), through the same 40ms queue as the SGR reports. Half a screen of
travel per page: the page key always jumps a whole screen, and a 1:1 mapping was
unusably slow with a discrete wheel. Shift is excluded — it keeps meaning "local
scrollback". Verified live: opt-out ON on a Claude session sends real PageUp/PageDown to
the PTY where the wheel previously did nothing.
3. **Probe caching** — `getClaudeCliVersion()` no longer caches failure. Success is kept for
the process lifetime; a failed probe retries with a 1/2/4…15min backoff. The cache policy
is a pure function (`resolveClaudeCliVersion`) so the retry semantics are unit-testable
without spawning `claude`. The VITEST short-circuit now records nothing, where before it
wrote a permanent null.
4. **Opt-out footgun** — handled by pairing rather than by scoping: the setting keeps meaning
exactly what it says (the wheel goes local), and fix 2 catches the case where "local" is
empty. Scoping the setting away from repaint-mode CLIs would have silently overridden an
explicit user choice. The App Settings tooltip now says to leave it off for Claude/Codex.
5. **Diagnostic** — `_logScrollRouting()` prints one line per session per distinct decision:
`[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…,
cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. That
single line answers every open question in the list below.
Still unanswered by code alone: whether mtiller's Claude Code is genuinely older than
2.1.187 (false-path 3), and whether the iPhone was running stale JS. The diagnostic makes
both self-reporting, so the retest ask is now "open the console and paste the `[scroll]` line".
### What to get from mtiller (some already asked)
- Shift+scroll behavior on Firefox (distinguishes hollow-local from handler-not-firing).
- `claude --version` on the Mac (decides false-paths 2 vs 3).
- App Settings → Input → "Wheel scrolls local history" state (false-path 1).
- iPhone: Claude or shell session, and whether a full tab kill changes anything.
- Browser console: `app.terminalUi?.terminal?.modes?.mouseTrackingMode` (false-path 4).
Original plan follows.
## Reports
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.12.0",
"version": "1.12.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.12.0",
"version": "1.12.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.12.0",
"version": "1.12.2",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview Bounded descendant walk over a process-tree snapshot.
*
* Split out of `tmux-manager.ts` so the traversal can be unit-tested directly. It
* previously lived as a private method, which meant the regression test had to keep
* its own copy of the algorithm — a test that passes while the shipped code rots.
*
* ## The incident this guards against
*
* On 2026-07-30 an unbounded version of this walk took a machine down. It ran
* `pgrep -P <pid>` once per node and recursed with no visited set, no depth limit and
* no node cap. Across ~28 adopted tmux trees the fan-out exploded, and because each
* `pgrep` blocks in the WSL kernel while reading `/proc/<pid>/cgroup`, none of them
* returned while the walk kept spawning more. Result: ~13,000 `pgrep` processes stuck
* in D-state out of ~39,000 total, load average above 13,000, and a machine only
* recoverable by restarting WSL — which cost every running session.
*
* Three properties make that impossible, and each has a test:
* 1. a cycle terminates instead of looping (stale snapshots can contain one),
* 2. depth is capped,
* 3. node count is capped.
*
* The fourth property — spawning nothing per node — is structural: this function
* takes a snapshot and cannot spawn anything at all.
*
* @module proc-tree
*/
/** Maximum generations to descend. Deeper than any real agent process tree. */
export const PROC_WALK_MAX_DEPTH = 10;
/** Hard ceiling on collected descendants. A backstop, not an expected limit. */
export const PROC_WALK_MAX_NODES = 500;
export interface WalkOptions {
maxDepth?: number;
maxNodes?: number;
/**
* Called once when a cap truncated the result, with which cap it was. Both are
* reported: a silent depth cap would hide a deep tree just as effectively as a
* silent node cap hides a wide one, and the whole point of this module is that
* truncation is visible rather than mysterious.
*/
onTruncated?: (pid: number, cap: number, reason: 'nodes' | 'depth') => void;
}
/**
* All descendants of `pid`, breadth-first and bounded.
*
* @param pid root of the walk; never included in the result
* @param byParent parent pid → child pids, from ONE `ps` snapshot
*/
export function collectDescendants(
pid: number,
byParent: ReadonlyMap<number, readonly number[]>,
opts: WalkOptions = {}
): number[] {
const maxDepth = opts.maxDepth ?? PROC_WALK_MAX_DEPTH;
const maxNodes = opts.maxNodes ?? PROC_WALK_MAX_NODES;
const out: number[] = [];
const visited = new Set<number>([pid]);
let frontier = [pid];
for (let depth = 0; depth < maxDepth && frontier.length; depth += 1) {
const next: number[] = [];
for (const parent of frontier) {
for (const child of byParent.get(parent) ?? []) {
if (visited.has(child)) continue; // a real tree has no cycles, a stale
visited.add(child); // snapshot can still produce one
out.push(child);
next.push(child);
if (out.length >= maxNodes) {
opts.onTruncated?.(pid, maxNodes, 'nodes');
return out;
}
}
}
frontier = next;
// Ran out of generations while descendants were still queued: the tree is
// deeper than the cap and the result is incomplete.
if (depth === maxDepth - 1 && frontier.length > 0) {
opts.onTruncated?.(pid, maxDepth, 'depth');
}
}
return out;
}
+26 -6
View File
@@ -2620,20 +2620,23 @@ export class Session extends EventEmitter {
* For interactive sessions, this is how you send user input to Claude.
* Remember to include `\r` (carriage return) to simulate pressing Enter.
*
* @param data - The input data to send (text, escape sequences, etc.)
*
* @example
* ```typescript
* session.write('hello world'); // Text only, no Enter
* session.write('\r'); // Enter key
* session.write('ls -la\r'); // Command with Enter
* ```
*
* @param data - The input data to send (text, escape sequences, etc.)
* @returns true if the data reached a PTY. A session whose PTY is gone still
* discards the data, but it used to do so with no signal at all — which is how
* input could disappear while the caller believed it had been delivered.
*/
write(data: string): void {
write(data: string): boolean {
this._trackSubmit(data);
if (this.ptyProcess) {
this.ptyProcess.write(data);
}
if (!this.ptyProcess) return false;
this.ptyProcess.write(data);
return true;
}
// ── Conversation tracking ─────────────────────────────────────────────
@@ -2691,6 +2694,23 @@ export class Session extends EventEmitter {
return true;
}
/**
* Undo the bookkeeping of {@link shouldApplyInput} for a delivery that failed.
*
* Without this, the reliable-delivery layer guarantees exactly-once delivery of
* something that may never have been delivered: the seq is recorded as applied
* BEFORE the write is attempted, so a client retry — the very mechanism the seq
* exists for — is rejected as a duplicate and the input is lost for good.
*
* Only rolls back if `seq` is still the newest recorded one; a later input has
* already superseded it and must not be re-opened.
*/
forgetInputSeq(clientId: string, seq: number): void {
if (this._appliedInputSeq.get(clientId) === seq) {
this._appliedInputSeq.set(clientId, seq - 1);
}
}
/**
* Sends input via the terminal multiplexer's direct input mechanism.
*
+117 -40
View File
@@ -22,7 +22,8 @@
*/
import { EventEmitter } from 'node:events';
import { execSync, exec } from 'node:child_process';
import { collectDescendants } from './proc-tree.js';
import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
@@ -100,6 +101,16 @@ import {
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
/**
* How long the kill path waits for a fresh snapshot before giving up on it.
* Shorter than EXEC_TIMEOUT_MS on purpose: killSession has two further strategies
* (process group, tmux kill-session) and must reach them even when `ps` is wedged.
*/
const PROC_SNAPSHOT_WAIT_MS = 1500;
import { DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from './config/terminal-history.js';
/**
@@ -2094,27 +2105,102 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Get all child process PIDs recursively
private getChildPids(pid: number): number[] {
const pids: number[] = [];
try {
const output = execSync(`pgrep -P ${pid}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (output) {
for (const childPid of output
.split('\n')
.map((p) => parseInt(p, 10))
.filter((p) => !Number.isNaN(p))) {
pids.push(childPid);
pids.push(...this.getChildPids(childPid));
/** One `ps` snapshot of the whole process table, cached briefly. */
private static procSnapshot: { at: number; byParent: Map<number, number[]> } | null = null;
/** Single-flight guard so a hung `ps` cannot pile up parallel refreshes. */
private static procRefresh: { started: number; promise: Promise<Map<number, number[]>> } | null = null;
/**
* Fork ONE `ps` asynchronously and cache the parent -> children map.
*
* Async on purpose: a synchronous fork here would block the event loop on every
* stats tick, and under the procfs pathology this module exists to survive,
* `execSync`'s timeout cannot return at all (spawnSync waits for the unkillable
* child) — freezing the whole server where a hung async poll only costs staleness.
*/
private static refreshProcSnapshot(): Promise<Map<number, number[]>> {
const inFlight = TmuxManager.procRefresh;
// Reuse an in-flight refresh — unless it is old enough to be presumed stuck.
if (inFlight && Date.now() - inFlight.started < EXEC_TIMEOUT_MS * 2) return inFlight.promise;
const started = Date.now();
const promise = new Promise<Map<number, number[]>>((resolve) => {
execFile('ps', ['-eo', 'pid=,ppid='], { timeout: EXEC_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
if (TmuxManager.procRefresh?.started === started) TmuxManager.procRefresh = null;
if (err) {
// ANY error, not just an empty one: a timed-out or truncated `ps` yields
// partial output, and caching that as fresh would make whole subtrees
// invisible — including to the kill path. Stale beats wrong.
console.error('[TmuxManager] process snapshot failed:', err);
resolve(TmuxManager.procSnapshot?.byParent ?? new Map());
return;
}
}
} catch {
// No children or command failed
const byParent = new Map<number, number[]>();
for (const line of String(out).split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length < 2) continue;
const pid = parseInt(parts[0], 10);
const ppid = parseInt(parts[1], 10);
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
const list = byParent.get(ppid);
if (list) list.push(pid);
else byParent.set(ppid, [pid]);
}
TmuxManager.procSnapshot = { at: Date.now(), byParent };
resolve(byParent);
});
});
TmuxManager.procRefresh = { started, promise };
return promise;
}
/**
* Best snapshot WITHOUT forking: returns the cache, kicking off a background
* refresh when it has gone stale, and never blocks. Stats and window-title
* consumers tolerate data one interval old; nothing that KILLS may use this.
*/
private childrenByParent(): Map<number, number[]> {
const cached = TmuxManager.procSnapshot;
if (!cached || Date.now() - cached.at >= PROC_SNAPSHOT_TTL_MS) {
void TmuxManager.refreshProcSnapshot();
}
return pids;
return cached?.byParent ?? new Map();
}
/**
* Descendants from a snapshot that is not the cached one — the kill path's variant.
*
* killSession re-scans for survivors between SIGTERM and SIGKILL, and the wait in
* between (200ms) sits far inside the cache TTL (2000ms): reading the cache there
* returns the pre-SIGTERM state verbatim, so children spawned since are invisible
* and SIGKILL aims at stale PIDs, guarded only by kill(pid, 0) — which cannot
* detect PID reuse.
*
* It forces a refresh rather than guaranteeing recency: an already-running refresh
* is reused, so the snapshot can predate this call by up to one `ps` runtime. A
* strict postdate guarantee would mean chaining a second `ps` behind every
* in-flight one, which is the fork storm this code exists to avoid.
*
* Bounded by design: waiting forever would freeze killSession before it reaches
* its process-group and tmux fallbacks.
*/
private async getChildPidsFresh(pid: number): Promise<number[]> {
let byParent: ReadonlyMap<number, readonly number[]>;
try {
byParent = await Promise.race([
TmuxManager.refreshProcSnapshot(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('proc snapshot timeout')), PROC_SNAPSHOT_WAIT_MS)
),
]);
} catch {
console.warn('[TmuxManager] process snapshot did not return in time; using the cached one');
byParent = TmuxManager.procSnapshot?.byParent ?? new Map<number, number[]>();
}
return collectDescendants(pid, byParent, {
onTruncated: (root, cap, reason) =>
console.warn(`[TmuxManager] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
});
}
// Check if a process is still alive
@@ -2221,7 +2307,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const allPids: number[] = [currentPid];
// Strategy 1: Kill all child processes recursively
let childPids = this.getChildPids(currentPid);
let childPids = await this.getChildPidsFresh(currentPid);
if (childPids.length > 0) {
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
allPids.push(...childPids);
@@ -2238,7 +2324,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
childPids = this.getChildPids(currentPid);
childPids = await this.getChildPidsFresh(currentPid);
for (const childPid of childPids) {
if (this.isProcessAlive(childPid)) {
try {
@@ -2452,17 +2538,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
// From the shared snapshot: this runs per session on every stats tick, and a
// pgrep per session was a fork per session per interval.
let childCount = 0;
try {
const childOutput = (
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
childCount = parseInt(childOutput, 10) || 0;
childCount = (this.childrenByParent().get(session.pid) ?? []).length;
} catch {
// No children or command failed
// No children or snapshot unavailable
}
return {
@@ -2496,17 +2578,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 1: Get descendant PIDs
const descendantMap = new Map<number, number[]>();
const pgrepOutput = (
await execAsync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
)
).stdout.trim();
// Derived from the ONE snapshot instead of a shell loop that forks a pgrep
// per session — the shape that turned into a fork storm under load.
const byParent = this.childrenByParent();
const childLines = sessionPids.map((p) => `${p}:${(byParent.get(p) ?? []).join(',')}`).join('\n');
for (const line of pgrepOutput.split('\n')) {
for (const line of childLines.split('\n')) {
const [pidStr, childrenStr] = line.split(':');
const sessionPid = parseInt(pidStr, 10);
if (!Number.isNaN(sessionPid)) {
+95 -25
View File
@@ -108,12 +108,100 @@ export function getAugmentedPath(): string {
return _augmentedPath;
}
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
let _claudeVersion: string | null | undefined = undefined;
/**
* Cache state for the `claude --version` probe.
*
* `version` is only ever set from a SUCCESSFUL probe and then kept for the
* process lifetime (the binary can't change under a running server without a
* restart). Failures are tracked separately so they expire.
*/
export interface ClaudeVersionProbeState {
/** Successful probe result; `undefined` until one succeeds. */
version?: string;
/** Consecutive failed probes (drives the retry backoff). */
failures: number;
/** Timestamp of the most recent failed probe. */
lastFailureAt: number;
}
/** First retry window after a failed probe. */
const VERSION_PROBE_BASE_RETRY_MS = 60_000;
/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */
const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000;
/**
* How long to wait before re-probing after `failures` consecutive failures:
* 1min, 2min, 4min… capped at 15min. Exported for tests.
*/
export function claudeVersionRetryDelayMs(failures: number): number {
if (failures <= 0) return 0;
return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS);
}
/**
* Cache policy for the version probe, pure apart from the `state` it mutates
* and the injected `probe` (exported so tests can drive it with a fake clock).
*
* Success is cached forever; FAILURE is not. That asymmetry is the fix for a
* real shipped bug: the old cache stored `null` on any exception and guarded on
* `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a
* PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST
* Claude session start left `cliVersion` undefined for EVERY Claude session
* until the server restarted. An undefined `cliVersion` silently disables
* wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`),
* which is the only route to history in repaint mode: a dead wheel on every
* device at once, matching the issue #205 retest reports.
*
* Retries back off so a genuinely absent binary still can't spawn a probe per
* session start.
*/
export function resolveClaudeCliVersion(
state: ClaudeVersionProbeState,
now: number,
probe: () => string | null
): string | null {
if (state.version !== undefined) return state.version;
if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null;
let version: string | null = null;
try {
version = probe();
} catch {
version = null;
}
if (version) {
state.version = version;
state.failures = 0;
state.lastFailureAt = 0;
return version;
}
state.failures += 1;
state.lastFailureAt = now;
return null;
}
const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 };
/** One `claude --version` run. Throws on spawn/timeout failure. */
function probeClaudeCliVersion(): string | null {
const dir = findClaudeDir();
const bin = dir ? join(dir, 'claude') : 'claude';
// execFileSync (no shell) — the resolved path may contain spaces, and there
// is no untrusted input, but avoid a shell either way.
const out = execFileSync(bin, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
env: { ...process.env, PATH: getAugmentedPath() },
});
const match = out.match(/(\d+\.\d+\.\d+)/);
return match ? match[1] : null;
}
/**
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
* can't be determined. Runs `claude --version` once and caches the result.
* can't be determined. Runs `claude --version` at most once per successful
* resolution; failed probes retry with backoff (see `resolveClaudeCliVersion`).
*
* This is a deterministic alternative to scraping the interactive startup
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
@@ -122,28 +210,10 @@ let _claudeVersion: string | null | undefined = undefined;
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
*/
export function getClaudeCliVersion(): string | null {
if (_claudeVersion !== undefined) return _claudeVersion;
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
// it on the session directly.
if (process.env.VITEST) {
_claudeVersion = null;
return _claudeVersion;
}
try {
const dir = findClaudeDir();
const bin = dir ? join(dir, 'claude') : 'claude';
// execFileSync (no shell) — the resolved path may contain spaces, and there
// is no untrusted input, but avoid a shell either way.
const out = execFileSync(bin, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
env: { ...process.env, PATH: getAugmentedPath() },
});
const match = out.match(/(\d+\.\d+\.\d+)/);
_claudeVersion = match ? match[1] : null;
} catch {
_claudeVersion = null;
}
return _claudeVersion;
// it on the session directly. Deliberately does NOT touch the cache state:
// recording a phantom failure here would be the very poisoning this fixes.
if (process.env.VITEST) return null;
return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion);
}
+19 -1
View File
@@ -519,6 +519,10 @@ class CodemanApp {
// Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false;
// Sessions whose last re-pull came back THINNER than the live buffer (a
// repaint-mode CLI pane, where tmux keeps no history of its own). The pull is
// refused for those and retried far more slowly — see _maybeRefetchFullHistory.
this._fullHistoryRepullUseless = new Set();
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
@@ -4121,6 +4125,13 @@ class CodemanApp {
* On demand rather than automatic because that capture is unbounded-ish work: at
* the default history limit it can be megabytes, which is fine to pay when the
* user is explicitly reaching for history and not fine on every tab switch.
*
* NEVER a downgrade: for a repaint-mode CLI pane tmux keeps no history of its
* own, so the capture can be THINNER than what xterm already holds and the
* reset+rewrite below would delete history mid-scroll. `_replayWouldShrinkBuffer`
* (terminal-ui.js) is the guard, and a session that produced one useless re-pull
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
* every scroll-up (issue #205, round 2).
*/
async _maybeRefetchFullHistory() {
const sessionId = this.activeSessionId;
@@ -4129,7 +4140,8 @@ class CodemanApp {
const now = Date.now();
// Momentum scrolling fires this dozens of times per flick, and a burst of new
// output is the normal reason to want a re-pull, so cooldown rather than latch.
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return;
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
try {
@@ -4138,6 +4150,12 @@ class CodemanApp {
// Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
if (this._replayWouldShrinkBuffer(buffer)) {
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
this._logScrollRouting?.('repull-refused-downgrade');
return;
}
this._fullHistoryRepullUseless?.delete(sessionId);
const rowsBefore = this.terminal.buffer.active.length;
this._resetTerminalForReplay();
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
+1 -1
View File
@@ -1322,7 +1322,7 @@
</div>
<!-- Input Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Leave OFF for Claude/Codex sessions: those CLIs redraw the screen in place and keep no local scrollback, so the wheel would have almost nothing to scroll. In Claude sessions Codeman then falls back to paging the CLI's own transcript; Codex sessions have no such fallback, so the wheel goes dead. Shift+wheel always reaches local scrollback regardless.">
<div class="settings-item-text">
<span class="settings-item-label">Wheel Scrolls Local History</span>
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
+62 -37
View File
@@ -209,9 +209,13 @@ const MobileDetection = {
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
*/
const KeyboardHandler = {
VIEWPORT_SETTLE_MS: 80,
lastViewportHeight: 0,
keyboardVisible: false,
initialViewportHeight: 0,
_viewportSettleTimer: null,
_settleScrollToBottom: false,
_settlePending: false,
/** Initialize keyboard handling */
init() {
@@ -276,6 +280,12 @@ const KeyboardHandler = {
window.removeEventListener('scroll', this._windowScrollHandler);
this._windowScrollHandler = null;
}
if (this._viewportSettleTimer) {
clearTimeout(this._viewportSettleTimer);
this._viewportSettleTimer = null;
}
this._settleScrollToBottom = false;
this._settlePending = false;
},
/** Handle viewport resize (keyboard show/hide) */
@@ -313,6 +323,7 @@ const KeyboardHandler = {
}
this.updateLayoutForKeyboard();
this._deferViewportSettle();
this.lastViewportHeight = currentHeight;
},
@@ -414,32 +425,9 @@ const KeyboardHandler = {
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
window.scrollTo(0, 0);
// Refit terminal locally AND send resize to server so Claude Code (Ink)
// knows the actual terminal dimensions. Without this, Ink redraws at the
// old (larger) row count when the user types, causing content to scroll
// off the visible area with each keystroke.
// Note: the throttledResize handler still suppresses ongoing resize events
// while keyboard is up — this one-shot resize on open/close is sufficient.
setTimeout(() => {
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
// Eliminate terminal row quantization gap: xterm can only show whole
// rows, so leftover pixels create dead space below the last row.
// Shrink .main's paddingBottom by the gap so the terminal fills flush
// to the accessory bar.
this._shrinkPaddingToFit();
app.terminal.scrollToBottom();
app._syncMobileHelperTextareaToCursor?.();
app._localEchoOverlay?.rerender?.();
// Send resize to server so PTY dimensions match xterm
this._sendTerminalResize();
}
// Reset again after fit/resize in case layout changes triggered scroll
window.scrollTo(0, 0);
}, 150);
// visualViewport emits multiple heights throughout the OS animation.
// Re-schedule on every event and fit only after the final height settles.
this._scheduleViewportSettle({ scrollToBottom: true });
// Reposition subagent windows to stack from bottom (above keyboard)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
@@ -454,22 +442,59 @@ const KeyboardHandler = {
this.resetLayout();
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
setTimeout(() => {
if (typeof app !== 'undefined' && app.fitAddon) {
try {
app.fitAddon.fit();
} catch {}
if (app.terminal) app.terminal.scrollToBottom();
// Send resize to server to restore full terminal size
this._sendTerminalResize();
}
}, 100);
this._scheduleViewportSettle({ scrollToBottom: true });
// Reposition subagent windows to stack from top (below header)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
},
/**
* Coalesce the keyboard animation into one final xterm reflow and PTY resize.
* Only a real show/hide transition arms the settle work; ongoing viewport
* resize events merely push a pending settle back (_deferViewportSettle).
* A viewport change that never crosses the show/hide thresholds must not
* refit: keyboard detection can miss a fine-grained OS animation entirely
* (each step under 150px, with the baseline chasing the animation), and the
* container is then mid-animation with no keyboard CSS compensation, so a
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
* garbles the transcript.
*/
_scheduleViewportSettle({ scrollToBottom = false } = {}) {
this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
this._settlePending = true;
this._armViewportSettleTimer();
},
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
_deferViewportSettle() {
if (!this._settlePending) return;
this._armViewportSettleTimer();
},
_armViewportSettleTimer() {
if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer);
this._viewportSettleTimer = setTimeout(() => {
this._viewportSettleTimer = null;
this._settlePending = false;
const shouldScrollToBottom = this._settleScrollToBottom;
this._settleScrollToBottom = false;
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) {
try {
app.fitAddon.fit();
} catch {}
}
if (this.keyboardVisible) this._shrinkPaddingToFit();
if (shouldScrollToBottom) app.terminal.scrollToBottom();
app._syncMobileHelperTextareaToCursor?.();
app._localEchoOverlay?.rerender?.();
this._sendTerminalResize();
}
window.scrollTo(0, 0);
}, this.VIEWPORT_SETTLE_MS);
},
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
_sendTerminalResize() {
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
+294 -8
View File
@@ -23,6 +23,44 @@
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
// Escape sequences occupy no terminal cells, so they must come out before a
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
// CSI, charset designators and the short escapes tmux emits; deliberately
// approximate — this feeds a size comparison, not a renderer.
// eslint-disable-next-line no-control-regex
const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
// (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~';
// Wheel/touch travel (in lines) that adds up to one PageUp/PageDown. Half a
// screen rather than a full one: the page key always jumps a whole screen, so
// a 1:1 mapping made the fallback feel unreachably slow with a discrete mouse
// wheel (Firefox reports 3 lines a notch → 12 notches per page). Overshooting
// the finger is the right trade against a gesture that otherwise does nothing.
const PAGE_KEY_SCREEN_FRACTION = 0.5;
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
// cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
// H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional
// modifier). Deliberately EXCLUDES terminal query responses that also
// arrive via onData (DA `\x1b[?1;2c`, CPR `\x1b[12;34R`) and function
// keys, so only genuine cursor/editing keys trigger the local-echo flush.
// eslint-disable-next-line no-control-regex
const COMPOSER_NAV_KEY_PATTERN = /^\x1b(?:\[(?:[ABCDHF]|1;[2-8][ABCDHF]|[1-8](?:;[2-8])?~)|O[ABCDHF])$/;
// Prefix xterm.js puts on terminal.paste() payloads while the application
// has bracketed-paste mode (DECSET 2004) enabled. Codex, Claude Code and
// tmux all enable it, so browser pastes arrive as one onData chunk of
// `\x1b[200~<text>\x1b[201~`.
const BRACKETED_PASTE_START = '\x1b[200~';
function isComposerNavKey(data) {
return COMPOSER_NAV_KEY_PATTERN.test(data);
}
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
@@ -60,8 +98,15 @@
global.CodemanTerminalInput = {
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
isComposerNavKey,
BRACKETED_PASTE_START,
USER_SCROLL_STICKY_SUPPRESS_MS,
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
REPLAY_ESCAPE_RE,
KEY_PAGE_UP,
KEY_PAGE_DOWN,
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
@@ -452,6 +497,7 @@ Object.assign(CodemanApp.prototype, {
ev.preventDefault();
ev.stopPropagation();
if (this._shouldForwardWheelToApp(ev)) {
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev));
return;
}
@@ -459,6 +505,10 @@ Object.assign(CodemanApp.prototype, {
// a stream of tiny pixel deltas, and rounding each one to a whole line
// (the ±1 fallback) made slow drags scroll faster than the finger.
const lines = this._wheelScrollLinesFloat(ev);
// …unless there is no local scrollback to scroll, in which case page the
// CLI's own transcript instead of doing nothing (_maybePageCliTranscript).
if (this._maybePageCliTranscript(ev, lines)) return;
this._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines);
this._smoothScrollBy(lines);
},
@@ -476,7 +526,10 @@ Object.assign(CodemanApp.prototype, {
// drags the CLI's pinned input box off the screen (issue #205's mobile
// half). Same gate, so Shift has no touch analog but the local-scrollback
// opt-out setting and the CLI-version gate apply to touch exactly as they
// do to the wheel.
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
// when that gate is false and there is no local scrollback to scroll
// (_maybePageCliTranscript), which is what keeps a swipe from being a
// complete no-op on a phone.
{
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
let touchLastX = 0;
@@ -498,7 +551,7 @@ Object.assign(CodemanApp.prototype, {
// Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else {
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
this.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines);
}
@@ -566,8 +619,10 @@ Object.assign(CodemanApp.prototype, {
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else {
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
this._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines);
@@ -832,11 +887,23 @@ Object.assign(CodemanApp.prototype, {
}
this._lastTerminalData = { data, time: performance.now() };
// ── Local Echo Pass-through ──
// After a composer nav key (arrow/Home/End/Delete) the real cursor may
// sit mid-text, where the overlay's append-only buffering would corrupt
// both the preview and the submitted text. Such sessions are handed
// back to plain PTY echo until Enter or Ctrl+C submits/cancels the
// composer line (see the nav-key branch below).
const echoPassthrough =
this._localEchoEnabled && this._echoPassthroughSessions?.has(this.activeSessionId);
if (echoPassthrough && (data === '\r' || data === '\x03')) {
this._echoPassthroughSessions.delete(this.activeSessionId);
}
// ── Local Echo Mode ──
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
if (this._localEchoEnabled) {
if (this._localEchoEnabled && !echoPassthrough) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
if (source === 'flushed') {
@@ -853,9 +920,16 @@ Object.assign(CodemanApp.prototype, {
}
this._pendingInput += data;
flushInput();
} else if (source === false) {
// Nothing pending, nothing flushed, nothing detected. The
// composer may still hold text the overlay cannot see (buffer
// detection is suppressed after a control-char flush), so
// forward the backspace instead of swallowing it (issue #218);
// an empty composer ignores it.
this._pendingInput += data;
flushInput();
}
// 'pending' = removed unsent text (no PTY backspace needed)
// false = nothing to remove (swallow the backspace)
return;
}
if (/^[\r\n]+$/.test(data)) {
@@ -897,6 +971,41 @@ Object.assign(CodemanApp.prototype, {
// Single-byte ESC (user pressing Escape) still falls through to
// the control char handler below.
if (data.length > 1 && data.charCodeAt(0) === 27) {
// Bracketed paste (terminal.paste() while DECSET 2004 is on):
// flush typed-but-unsent overlay text FIRST so the pasted block
// lands after it in the composer, not before it (issue #219).
// The paste sequence gets its own delayed write: Codex's
// paste-burst handling drops keystrokes that arrive in the SAME
// PTY read as a bracketed paste (verified against codex 0.147),
// mirroring the delayed \r in the Enter branch above.
if (data.startsWith(window.CodemanTerminalInput.BRACKETED_PASTE_START)) {
const hadPending = !!this._localEchoOverlay?.pendingText;
this._flushLocalEchoPending();
if (hadPending) {
flushInput();
setTimeout(() => {
this._pendingInput += data;
flushInput();
}, 80);
} else {
this._pendingInput += data;
flushInput();
}
return;
}
// Composer nav keys (arrows, Home/End, Delete, PgUp/PgDn):
// flush unsent text so the key edits the real composer state,
// then hand the session to plain PTY echo until Enter/Ctrl+C.
// The cursor may now sit mid-text, where append-only buffering
// cannot track edits (issue #218).
if (window.CodemanTerminalInput.isComposerNavKey(data)) {
this._flushLocalEchoPending();
if (!this._echoPassthroughSessions) this._echoPassthroughSessions = new Set();
this._echoPassthroughSessions.add(this.activeSessionId);
this._pendingInput += data;
flushInput();
return;
}
// Multi-byte escape sequence — forward to PTY without clearing
// overlay/flushed state (terminal response, not user input)
this._pendingInput += data;
@@ -2079,6 +2188,54 @@ Object.assign(CodemanApp.prototype, {
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
},
/**
* Rows a `?full=1` capture will occupy once written into xterm.
*
* tmux joins wrapped rows in that capture (`capture-pane -J`), so a long
* logical line re-wraps into several xterm rows on write and a bare newline
* count would undershoot; escape sequences occupy no cells and come out
* first. Approximate by construction (it ignores double-width glyphs), which
* is fine: the only consumer is a coarse size comparison
* (_replayWouldShrinkBuffer), and it runs once per cooldown-guarded re-pull.
*/
_estimateReplayRows(text, cols) {
if (typeof text !== 'string' || !text) return 0;
const width = cols > 0 ? cols : 80;
const plain = text.replace(window.CodemanTerminalInput.REPLAY_ESCAPE_RE, '');
let rows = 0;
for (const line of plain.split('\n')) {
const cells = line.endsWith('\r') ? line.length - 1 : line.length;
rows += cells > width ? Math.ceil(cells / width) : 1;
}
return rows;
},
/**
* DOWNGRADE GUARD for the scroll-to-top re-pull (issue #205, round 2).
*
* `_maybeRefetchFullHistory` resets the terminal and rewrites it from the
* capture, which is a straight win when tmux holds more than the browser —
* the burst-repaint and tab-switch losses it was built for. But a repaint-mode
* CLI pane keeps NO tmux history of its own (`history_size≈0` measured for a
* Claude pane), so there the capture is roughly ONE frame while xterm may hold
* hundreds of rows of replayed frames. Rewriting then DESTROYS history
* mid-scroll: exactly the "goes back a limited amount, repeats blocks, gets
* worse when I reach the top" report from the 1.12.0 retest.
*
* So refuse when the capture is smaller, with a one-screen tolerance because
* both sides are estimates: `buffer.active.length` includes the blank rows
* below the last line, and _estimateReplayRows can only approximate wrapping.
* Only a capture that is worse by more than a full screen counts as a
* downgrade, which leaves every genuine recovery case untouched.
*/
_replayWouldShrinkBuffer(capture) {
const term = this.terminal;
const rowsNow = term?.buffer?.active?.length || 0;
if (!rowsNow) return false;
const screen = term?.rows || 24;
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
},
/**
* Ease-out smooth scrolling for the local wheel path. The capture-phase
* wheel handler owns local scrolling (xterm's own smooth scroller is
@@ -2227,6 +2384,23 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Flush the local-echo overlay's unsent text into `_pendingInput` (no
* trailing Enter) and reset overlay + flushed-state tracking. Used before
* forwarding sequences that must arrive AFTER the typed text (bracketed
* paste, composer nav keys). The caller forwards its own sequence: nav keys
* ride the same write, pastes get a delayed second write because codex
* drops keys that share a PTY read with a bracketed paste.
*/
_flushLocalEchoPending() {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
this._localEchoOverlay?.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (text) this._pendingInput += text;
},
/**
* Update local echo overlay state based on settings.
* Enabled whenever the setting is on — works during idle AND busy.
@@ -2266,8 +2440,14 @@ Object.assign(CodemanApp.prototype, {
}
},
});
} else if (session.mode === 'shell') {
} else if (session.mode === 'shell' || session.mode === 'codex') {
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
// Codex mode: the composer is fully interactive per keystroke. Typing
// "/" pops a live-filtering command picker (issue #222), the composer
// grows and rewraps as it fills (#220), pastes are bracketed (#219)
// and arrows/history edit server-side state (#218). Buffering
// keystrokes until Enter starves all of that, so codex sessions use
// plain PTY echo like shell.
// Disable it by clearing any pending text.
this._localEchoOverlay.clear();
this._localEchoEnabled = false;
@@ -2671,7 +2851,11 @@ Object.assign(CodemanApp.prototype, {
/** Insert editable text at the active prompt without pressing Enter. */
insertTerminalText(text) {
if (!this.activeSessionId || !text) return;
if (this._localEchoEnabled && this._localEchoOverlay) {
if (
this._localEchoEnabled &&
this._localEchoOverlay &&
!this._echoPassthroughSessions?.has(this.activeSessionId)
) {
this._localEchoOverlay.appendText(text);
} else {
this.sendInput(text).catch(() => {});
@@ -2969,6 +3153,16 @@ Object.assign(CodemanApp.prototype, {
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
// it over forwarding the wheel to the CLI's transcript (issue #154). Cheap —
// loadAppSettingsFromStorage() is cache-backed.
//
// FOOTGUN, and why it is handled downstream rather than here: for a
// repaint-mode CLI that local scrollback is EMPTY (tmux keeps no history for
// the pane), so this setting can silently convert a working wheel into a
// dead one — a plausible reading of the #205 retest, where a user whose
// scrolling was broken on 1.11.x may well have flipped it while hunting for
// a fix. Scoping the setting away from those modes would be the other
// option, but it would override an explicit user choice; instead the caller
// falls through to _maybePageCliTranscript, so the gesture still pages the
// CLI's transcript and the setting keeps meaning exactly what it says.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = this.terminal?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
@@ -3012,13 +3206,105 @@ Object.assign(CodemanApp.prototype, {
if (!pos) return;
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), 5);
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
},
/**
* Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR
* wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a
* tmux send-keys server-side, so per-event writes would spawn a process storm
* on a single flick; the queue is bounded so a wild scroll can't build a
* backlog that keeps scrolling after the finger stops.
*/
_queueScrollBytes(data) {
if (!data || !this.activeSessionId) return;
const queued = this._wheelSgrQueue || '';
if (queued.length > 512) return;
this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
this._wheelSgrQueue = queued + data;
if (this._wheelSgrFlushTimer) return;
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
},
/**
* True when this session's LOCAL scrollback is structurally empty: a Claude
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
* overwrites the last, so xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
*/
_localScrollbackIsHollow() {
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude') return false;
const buf = this.terminal?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
return (buf.baseY || 0) === 0;
},
/**
* LAST-RESORT scroll for a hollow local buffer: translate gesture lines into
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
*
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
* on a Claude session that has no local history to fall back on: the CLI
* version probe failed or is genuinely older than 2.1.187, or the user turned
* on "Wheel scrolls local history" (which pins the wheel to a buffer that,
* for a repaint-mode CLI, is empty — the setting's footgun). Before this, all
* of those produced a completely dead gesture; the #205 reporter proved the
* keyboard route works by paging back through intact text with Fn+Up.
*
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
* real local scrollback is never touched. Shift is excluded on purpose: it is
* the explicit "give me local scrollback" gesture and must keep that meaning.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) return false;
// Leftover travel belongs to the tab it was made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
}
const tuning = window.CodemanTerminalInput;
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
const pending = (this._pageKeyPending || 0) + lines;
const pages = Math.trunc(pending / perPage);
this._pageKeyPending = pending - pages * perPage;
if (pages) {
const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN;
this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH)));
}
this._logScrollRouting('page-keys');
return true;
},
/**
* One line in the console saying WHY a scroll gesture went where it went.
*
* Issue #205 ran two rounds of remote guesswork — is the CLI version probe
* empty, is the opt-out setting on, did a mouse DECSET leak past the strip? —
* that this single log answers directly. Logged once per session per distinct
* decision, so a steady gesture stays silent and a CHANGE (e.g. the version
* arriving late and flipping the route) still prints.
*/
_logScrollRouting(decision) {
const sessionId = this.activeSessionId || '(none)';
const session = this.sessions?.get(sessionId);
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`;
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
this._scrollRoutingLogged.set(sessionId, signature);
console.log(
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})`
);
},
_flushWheelSgrQueue() {
this._wheelSgrFlushTimer = null;
const data = this._wheelSgrQueue;
+22
View File
@@ -640,6 +640,25 @@ async function buildExternalAttachmentRouteItem(
}
}
/**
* Headers Fastify already put on the reply, in a shape `writeHead` accepts.
*
* `reply.raw.writeHead()` writes straight to the Node response and bypasses
* Fastify's header store, so anything the security `onRequest` hook granted — CORS
* for localhost origins, nosniff, frame-options, CSP — is silently dropped on every
* route that answers this way. Spread this first and let the route's own headers
* win over it.
*/
function inheritedHeaders(reply: {
getHeaders(): NodeJS.Dict<number | string | string[]>;
}): Record<string, number | string | string[]> {
const out: Record<string, number | string | string[]> = {};
for (const [name, value] of Object.entries(reply.getHeaders())) {
if (value !== undefined) out[name] = value;
}
return out;
}
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
@@ -1337,6 +1356,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
if (download === 'true' || ext === 'svg') {
reply.raw.writeHead(200, {
...inheritedHeaders(reply),
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${basename}"`,
'Content-Length': content.length,
@@ -1576,6 +1596,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Set up SSE headers
reply.raw.writeHead(200, {
...inheritedHeaders(reply),
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
@@ -1709,6 +1730,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const content = await fs.readFile(resolvedPath);
// Bypass Fastify compression — write directly to raw response
reply.raw.writeHead(200, {
...inheritedHeaders(reply),
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': content.length,
+22 -9
View File
@@ -879,28 +879,41 @@ export function registerSessionRoutes(
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
// client's ACK, so a tagged duplicate redelivery must still return 200 but
// skip the write. Untagged requests (curl/legacy) always apply.
if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
const tagged = typeof clientId === 'string' && typeof seq === 'number';
if (tagged && !session.shouldApplyInput(clientId as string, seq as number)) {
return {};
}
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useMux) {
// Fire-and-forget: don't block HTTP response on tmux child process.
// Fallback to direct write on failure.
// Fire-and-forget: don't block the HTTP response on a tmux child process.
// Fallback to a direct write on failure.
//
// Because the response has already been sent by then, a failure here is the
// one case the caller can never learn about — so the dedup bookkeeping is
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
// very mechanism reliable delivery exists for, is rejected as a duplicate.
const undoOnFailure = () => {
if (tagged) session.forgetInputSeq(clientId as string, seq as number);
};
session
.writeViaMux(inputStr)
.then((ok) => {
if (!ok) {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
session.write(inputStr);
}
if (ok) return;
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
if (!session.write(inputStr)) undoOnFailure();
})
.catch(() => {
session.write(inputStr);
if (!session.write(inputStr)) undoOnFailure();
});
} else {
session.write(inputStr);
// Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx.
if (!session.write(inputStr) && tagged) {
session.forgetInputSeq(clientId as string, seq as number);
}
}
return {};
});
+8 -2
View File
@@ -180,13 +180,19 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
let delivered = true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
delivered = session.write(msg.d);
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
// a duplicate: the input was lost for good.
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
}
if (seq !== null && socket.readyState === 1) {
if (delivered && seq !== null && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${seq}}`);
}
} else if (
+17
View File
@@ -805,7 +805,24 @@ export class WebServer extends EventEmitter {
const clientId =
typeof query.clientId === 'string' && SSE_CLIENT_ID_RE.test(query.clientId) ? query.clientId : undefined;
// Carry over the headers the security hook already set on this reply.
//
// writeHead goes straight to the Node response and bypasses Fastify's header
// store, so everything the onRequest hook granted is silently dropped —
// including the Access-Control-Allow-Origin it emits for localhost origins.
// The result is an internal contradiction: a localhost page may call every
// /api endpoint cross-origin, but its EventSource fails CORS. The security
// headers (nosniff, frame-options, CSP) were lost the same way.
//
// The other raw-writeHead routes live in file-routes.ts and share a helper;
// this one keeps its own copy so the server does not import from a route
// module it registers.
const inherited: Record<string, number | string | string[]> = {};
for (const [name, value] of Object.entries(reply.getHeaders())) {
if (value !== undefined) inherited[name] = value;
}
reply.raw.writeHead(200, {
...inherited,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
+109
View File
@@ -0,0 +1,109 @@
/**
* Issue #205, round 2: `getClaudeCliVersion()` used to cache FAILURE forever.
*
* It stored `null` on any exception and guarded on `!== undefined`, so a single
* failed probe — the 5s exec timeout, a PATH-starved systemd/launchd
* environment, a transient fs hiccup — at the first Claude session start left
* `cliVersion` undefined for every Claude session until the server restarted.
* An undefined `cliVersion` silently disables wheel-forwarding to Claude's own
* transcript (`_shouldForwardWheelToApp`), which is the only route to history
* for a repaint-mode pane: a dead wheel on every device at once, which is what
* the reporter described (phone + iPad + laptop all broken together points at a
* SERVER-side cause, not a browser one).
*
* The probe itself can't run under vitest (it would spawn a real `claude`), so
* these drive the cache policy directly with an injected probe and clock.
*/
import { describe, expect, it, vi } from 'vitest';
import {
claudeVersionRetryDelayMs,
getClaudeCliVersion,
resolveClaudeCliVersion,
type ClaudeVersionProbeState,
} from '../src/utils/claude-cli-resolver.js';
const freshState = (): ClaudeVersionProbeState => ({ failures: 0, lastFailureAt: 0 });
describe('claude --version probe caching', () => {
it('probes once on success and never spawns again', () => {
const state = freshState();
const probe = vi.fn(() => '2.1.223');
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBe('2.1.223');
expect(resolveClaudeCliVersion(state, 2_000, probe)).toBe('2.1.223');
expect(resolveClaudeCliVersion(state, 9_999_999, probe)).toBe('2.1.223');
expect(probe).toHaveBeenCalledTimes(1);
});
it('RETRIES after a failed probe instead of poisoning the process', () => {
const state = freshState();
const probe = vi
.fn<() => string | null>()
.mockImplementationOnce(() => {
throw new Error('spawn claude ETIMEDOUT'); // the shipped failure mode
})
.mockImplementationOnce(() => '2.1.223');
// First session start: probe blows up, no version.
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull();
// Immediately after, the negative cache holds — no probe storm.
expect(resolveClaudeCliVersion(state, 30_000, probe)).toBeNull();
expect(probe).toHaveBeenCalledTimes(1);
// Once the retry window elapses, the next session start probes again and
// wheel-forwarding comes back without a server restart.
expect(resolveClaudeCliVersion(state, 61_000, probe)).toBe('2.1.223');
expect(probe).toHaveBeenCalledTimes(2);
});
it('treats an unparseable version like a failure (retryable, not cached)', () => {
const state = freshState();
const probe = vi.fn<() => string | null>(() => null); // e.g. output without a x.y.z
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull();
expect(resolveClaudeCliVersion(state, 61_000, probe)).toBeNull();
expect(probe).toHaveBeenCalledTimes(2);
expect(state.version).toBeUndefined(); // nothing cached as "known bad"
});
it('clears the failure streak once a probe succeeds', () => {
const state = freshState();
const probe = vi
.fn<() => string | null>()
.mockImplementationOnce(() => null)
.mockImplementationOnce(() => '2.1.223');
resolveClaudeCliVersion(state, 1_000, probe);
expect(state.failures).toBe(1);
resolveClaudeCliVersion(state, 61_000, probe);
expect(state.failures).toBe(0);
expect(state.lastFailureAt).toBe(0);
});
it('backs off so a genuinely missing binary cannot probe on every session start', () => {
expect(claudeVersionRetryDelayMs(0)).toBe(0);
expect(claudeVersionRetryDelayMs(1)).toBe(60_000);
expect(claudeVersionRetryDelayMs(2)).toBe(120_000);
expect(claudeVersionRetryDelayMs(3)).toBe(240_000);
// Capped, so it keeps retrying forever without ever spinning.
expect(claudeVersionRetryDelayMs(50)).toBe(15 * 60_000);
const state = freshState();
const probe = vi.fn<() => string | null>(() => null);
resolveClaudeCliVersion(state, 0, probe); // failure 1 → retry at 60s
resolveClaudeCliVersion(state, 30_000, probe); // still inside the window
expect(probe).toHaveBeenCalledTimes(1);
resolveClaudeCliVersion(state, 60_000, probe); // failure 2 → retry at 120s
resolveClaudeCliVersion(state, 119_000, probe);
expect(probe).toHaveBeenCalledTimes(2);
resolveClaudeCliVersion(state, 180_001, probe);
expect(probe).toHaveBeenCalledTimes(3);
});
it('stays hermetic under vitest without recording a phantom failure', () => {
// The guard returns before the probe, and — unlike the old code, which wrote
// null into the cache here — leaves the cache untouched.
expect(getClaudeCliVersion()).toBeNull();
expect(getClaudeCliVersion()).toBeNull();
});
});
+240
View File
@@ -0,0 +1,240 @@
/**
* @fileoverview Local-echo gating and input-ordering helpers for codex
* sessions (issues #218/#219/#220/#222).
*
* Codex's composer is interactive per keystroke: typing "/" pops a
* live-filtering command picker (#222), the composer grows as it wraps
* (#220), pastes arrive bracketed (#219) and arrows edit server-side state
* (#218). The buffer-until-Enter local echo overlay starves all of that, so
* codex-mode sessions must use plain PTY echo like shell. The shared overlay
* branch (claude/gemini/opencode) additionally flushes typed-but-unsent text
* before forwarding bracketed pastes and composer nav keys, and hands the
* session to pass-through after a nav key.
*
* Loaded via `vm` with a stubbed context (no jsdom), mirroring
* test/input-send-order.test.ts. End-to-end behavior was verified against a
* real codex 0.147.0 TUI in tmux through a headless browser.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
type OverlayStub = {
pendingText: string;
cleared: number;
suppressed: number;
prompts: unknown[];
clear(): void;
suppressBufferDetection(): void;
setPrompt(p: unknown): void;
appendText: ReturnType<typeof vi.fn>;
};
type AppInstance = {
activeSessionId: string | null;
sessions: Map<string, { mode: string }>;
terminal?: { focus: () => void };
_localEchoEnabled?: boolean;
_localEchoOverlay?: OverlayStub;
_pendingInput: string;
_flushedOffsets?: Map<string, number>;
_flushedTexts?: Map<string, string>;
_echoPassthroughSessions?: Set<string>;
loadAppSettingsFromStorage: () => Record<string, unknown>;
sendInput: ReturnType<typeof vi.fn>;
_updateLocalEchoState(): void;
_flushLocalEchoPending(): void;
insertTerminalText(text: string): void;
};
function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: {
length: 0,
key: vi.fn(),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
window: windowStub,
MobileDetection: {
isTouchDevice: () => true,
isHandheldDevice: () => false,
getDeviceType: () => 'desktop',
},
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
return {
CodemanApp,
terminalInput: (windowStub as { CodemanTerminalInput?: Record<string, unknown> }).CodemanTerminalInput!,
};
}
const { CodemanApp, terminalInput } = loadContext();
const isComposerNavKey = terminalInput.isComposerNavKey as (data: string) => boolean;
function makeOverlay(pending = ''): OverlayStub {
return {
pendingText: pending,
cleared: 0,
suppressed: 0,
prompts: [],
clear() {
this.cleared++;
this.pendingText = '';
},
suppressBufferDetection() {
this.suppressed++;
},
setPrompt(p: unknown) {
this.prompts.push(p);
},
appendText: vi.fn(),
};
}
function makeApp(mode: string, overlay = makeOverlay()): AppInstance {
const app = Object.create(CodemanApp.prototype) as AppInstance;
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { mode }]]);
app._localEchoOverlay = overlay;
app._pendingInput = '';
app._flushedOffsets = new Map([['s1', 3]]);
app._flushedTexts = new Map([['s1', 'abc']]);
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: true });
app.sendInput = vi.fn().mockResolvedValue(undefined);
return app;
}
describe('CodemanTerminalInput.isComposerNavKey', () => {
it.each([
'\x1b[A',
'\x1b[B',
'\x1b[C',
'\x1b[D',
'\x1b[H',
'\x1b[F',
'\x1bOA',
'\x1bOD',
'\x1bOH',
'\x1bOF',
'\x1b[1;5C', // Ctrl+Right
'\x1b[1;2A', // Shift+Up
'\x1b[3~', // Delete
'\x1b[3;5~', // Ctrl+Delete
'\x1b[5~', // PgUp
'\x1b[6~', // PgDn
'\x1b[1~', // Home variant
'\x1b[4~', // End variant
])('classifies %j as a composer nav key', (seq) => {
expect(isComposerNavKey(seq)).toBe(true);
});
it.each([
'\x1b[?1;2c', // DA1 response
'\x1b[>0;276;0c', // DA2 response
'\x1b[12;34R', // CPR response
'\x1b[1;3R', // CPR response (small coords)
'\x1b[0n', // DSR response
'\x1b[15~', // F5 (function keys stay out)
'\x1b[200~hi\x1b[201~', // bracketed paste
'\x1b[?u', // kitty keyboard query response
'\x1bOP', // F1
'\x1b',
'a',
'abc',
'\r',
])('does NOT classify %j as a composer nav key', (seq) => {
expect(isComposerNavKey(seq)).toBe(false);
});
it('exports the bracketed paste prefix xterm puts on terminal.paste()', () => {
expect(terminalInput.BRACKETED_PASTE_START).toBe('\x1b[200~');
});
});
describe('_updateLocalEchoState mode gating', () => {
it('disables the overlay for codex sessions even with the setting ON (issues #218/#219/#220/#222)', () => {
const overlay = makeOverlay('pending');
const app = makeApp('codex', overlay);
app._updateLocalEchoState();
expect(app._localEchoEnabled).toBe(false);
expect(overlay.cleared).toBeGreaterThan(0);
});
it('disables the overlay for shell sessions (PTY provides its own echo)', () => {
const app = makeApp('shell');
app._updateLocalEchoState();
expect(app._localEchoEnabled).toBe(false);
});
it.each(['claude', 'gemini', 'opencode'])('keeps the overlay enabled for %s sessions', (mode) => {
const overlay = makeOverlay();
const app = makeApp(mode, overlay);
app._updateLocalEchoState();
expect(app._localEchoEnabled).toBe(true);
expect(overlay.prompts.length).toBeGreaterThan(0);
});
});
describe('_flushLocalEchoPending', () => {
it('moves pending text into _pendingInput and resets overlay + flushed tracking', () => {
const overlay = makeOverlay('hello');
const app = makeApp('claude', overlay);
app._flushLocalEchoPending();
expect(app._pendingInput).toBe('hello');
expect(overlay.cleared).toBe(1);
expect(overlay.suppressed).toBe(1);
expect(app._flushedOffsets!.has('s1')).toBe(false);
expect(app._flushedTexts!.has('s1')).toBe(false);
});
it('appends nothing when the overlay is empty', () => {
const app = makeApp('claude', makeOverlay(''));
app._flushLocalEchoPending();
expect(app._pendingInput).toBe('');
});
});
describe('insertTerminalText pass-through routing', () => {
it('appends to the overlay while local echo is buffering', () => {
const overlay = makeOverlay();
const app = makeApp('claude', overlay);
app._localEchoEnabled = true;
app.insertTerminalText('path.txt');
expect(overlay.appendText).toHaveBeenCalledWith('path.txt');
expect(app.sendInput).not.toHaveBeenCalled();
});
it('sends directly while the session is in nav-key pass-through', () => {
const overlay = makeOverlay();
const app = makeApp('claude', overlay);
app._localEchoEnabled = true;
app._echoPassthroughSessions = new Set(['s1']);
app.insertTerminalText('path.txt');
expect(app.sendInput).toHaveBeenCalledWith('path.txt');
expect(overlay.appendText).not.toHaveBeenCalled();
});
});
+140
View File
@@ -323,6 +323,146 @@ describe('Virtual Keyboard', () => {
expect(mainPadding).toBe('');
});
it('coalesces keyboard animation frames into one final terminal fit', async () => {
const result = await page.evaluate(async () => {
// `app.terminal` and `app.fitAddon` are only assigned by initTerminal(),
// which needs a selected session this harness never creates. Both are
// null at rest, and the settle callback returns early on a falsy
// terminal — so without stand-ins this test cannot reach the behavior
// it asserts. Install the minimum surface the callback touches.
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal);
let fits = 0;
let resizes = 0;
let bottomRestores = 0;
app.fitAddon.fit = () => {
fits++;
};
KeyboardHandler._sendTerminalResize = () => {
resizes++;
};
app.terminal.scrollToBottom = () => {
bottomRestores++;
};
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._scheduleViewportSettle();
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._scheduleViewportSettle();
await new Promise((resolve) => setTimeout(resolve, 50));
const beforeFinalSettle = { fits, resizes, bottomRestores };
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS));
const afterFinalSettle = { fits, resizes, bottomRestores };
app.fitAddon.fit = originalFit;
KeyboardHandler._sendTerminalResize = originalSendResize;
app.terminal.scrollToBottom = originalScrollToBottom;
if (!hadFitAddon) app.fitAddon = null;
if (!hadTerminal) app.terminal = null;
return { beforeFinalSettle, afterFinalSettle };
});
expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 });
expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 1 });
});
// Behavioral counterpart to the test above, driven through the PUBLIC entry
// point rather than the internal scheduler. Before this change each
// onKeyboardShow armed its own uncoalesced 150ms setTimeout, so a keyboard
// animation that reports several viewport steps refit the terminal once per
// step — the visible symptom being repeated reflow while the keyboard slides
// up. This asserts the observable outcome (one refit for a burst) and so
// fails on master by COUNT, not by a missing method.
it('refits once for a burst of keyboard viewport steps', async () => {
const counts = await page.evaluate(async () => {
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
let fits = 0;
app.fitAddon.fit = () => {
fits++;
};
KeyboardHandler._sendTerminalResize = () => {};
// Three viewport steps in quick succession, as a keyboard animation
// produces on a real device.
KeyboardHandler.onKeyboardShow();
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler.onKeyboardShow();
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler.onKeyboardShow();
// Well past both the coalescing window and master's fixed 150ms timer.
await new Promise((resolve) => setTimeout(resolve, 400));
app.fitAddon.fit = originalFit;
KeyboardHandler._sendTerminalResize = originalSendResize;
if (!hadFitAddon) app.fitAddon = null;
if (!hadTerminal) app.terminal = null;
return fits;
});
// Coalesced: one refit for the whole burst. Master fires one per step.
expect(counts).toBe(1);
});
// A viewport resize with NO pending show/hide transition must not arm settle
// work of its own: keyboard detection can miss a fine-grained OS animation
// entirely (sub-150px steps with the baseline chasing the animation), and a
// fit against that mid-animation, uncompensated layout resizes the PTY to
// transient dims. The resulting SIGWINCH thrash duplicates prompts and
// garbles the transcript. Wiggles may only push a pending settle back.
it('does not refit on viewport wiggles without a keyboard transition', async () => {
const result = await page.evaluate(async () => {
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
let fits = 0;
app.fitAddon.fit = () => {
fits++;
};
KeyboardHandler._sendTerminalResize = () => {};
// Wiggle only: nothing pending, so nothing may fire.
KeyboardHandler._deferViewportSettle();
KeyboardHandler._deferViewportSettle();
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
const wiggleOnly = fits;
// A real transition arms the work; a following wiggle defers it but the
// settle still fires exactly once.
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._deferViewportSettle();
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
const afterTransition = fits;
app.fitAddon.fit = originalFit;
KeyboardHandler._sendTerminalResize = originalSendResize;
if (!hadFitAddon) app.fitAddon = null;
if (!hadTerminal) app.terminal = null;
return { wiggleOnly, afterTransition };
});
expect(result.wiggleOnly).toBe(0);
expect(result.afterTransition).toBe(1);
});
it('accessory bar has the simple-mode action buttons', async () => {
const actions = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
+15 -2
View File
@@ -30,13 +30,22 @@ export class MockSession extends EventEmitter {
this._muxName = `codeman-test-${id.slice(0, 8)}`;
}
/** Direct PTY write (used by session.write()) */
write(data: string): void {
/**
* Set to simulate a session whose PTY is gone: both write paths report failure,
* which is the state in which input used to disappear silently.
*/
failWrites = false;
/** Direct PTY write (used by session.write()). Mirrors the real boolean return. */
write(data: string): boolean {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
}
/** Write via mux (used by respawn controller) */
async writeViaMux(data: string): Promise<boolean> {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
}
@@ -44,6 +53,10 @@ export class MockSession extends EventEmitter {
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
* exercising the reliable-delivery path behave like production. */
private _appliedInputSeq = new Map<string, number>();
forgetInputSeq(clientId: string, seq: number): void {
if (this._appliedInputSeq.get(clientId) === seq) this._appliedInputSeq.set(clientId, seq - 1);
}
shouldApplyInput(clientId: string, seq: number): boolean {
const last = this._appliedInputSeq.get(clientId);
if (last !== undefined && seq <= last) return false;
+200
View File
@@ -0,0 +1,200 @@
/**
* Regression guard for the process-tree walk.
*
* On 2026-07-30 an unbounded version took a machine down: it ran `pgrep -P <pid>` per
* node and recursed with no visited set, no depth limit and no node cap. Across ~28
* adopted tmux trees the fan-out exploded, and because each `pgrep` blocks in the WSL
* kernel while reading /proc/<pid>/cgroup, none returned while the walk kept firing
* more. Result: ~13,000 `pgrep` processes stuck in D-state out of ~39,000 total, load
* average above 13,000, recoverable only by
* restarting WSL — which cost every running session.
*
* These tests exercise the SHIPPED function. An earlier version of this file carried
* its own copy of the traversal, which would have passed happily while the real code
* regressed; that is why the walk now lives in its own module.
*/
import { describe, expect, it, vi } from 'vitest';
import { collectDescendants, PROC_WALK_MAX_DEPTH, PROC_WALK_MAX_NODES } from '../src/proc-tree.js';
/** Build a parent→children map from `[parent, child]` pairs. */
function tree(pairs: [number, number][]): Map<number, number[]> {
const m = new Map<number, number[]>();
for (const [p, c] of pairs) m.set(p, [...(m.get(p) ?? []), c]);
return m;
}
/** A chain 1→2→…→n, i.e. depth n-1. */
function chain(n: number): Map<number, number[]> {
return tree(Array.from({ length: n - 1 }, (_, i) => [i + 1, i + 2] as [number, number]));
}
describe('collectDescendants', () => {
it('returns every descendant of a normal tree, root excluded', () => {
const t = tree([
[1, 2],
[1, 3],
[2, 4],
[3, 5],
]);
expect(collectDescendants(1, t).sort((a, b) => a - b)).toEqual([2, 3, 4, 5]);
});
it('terminates on a cycle instead of looping forever', () => {
// A live `ps` snapshot is not atomic; pid reuse can produce a parent loop.
const t = tree([
[1, 2],
[2, 3],
[3, 1],
[3, 2],
]);
expect(collectDescendants(1, t).sort((a, b) => a - b)).toEqual([2, 3]);
});
it('does not include the root even when something claims it as a child', () => {
expect(collectDescendants(1, tree([[1, 1]]))).toEqual([]);
});
it('caps the depth, and says so', () => {
// 40 generations available, only PROC_WALK_MAX_DEPTH may be descended. A silent
// depth cap hides a deep tree exactly as a silent node cap hides a wide one.
const onTruncated = vi.fn();
expect(collectDescendants(1, chain(40), { onTruncated })).toHaveLength(PROC_WALK_MAX_DEPTH);
expect(onTruncated).toHaveBeenCalledWith(1, PROC_WALK_MAX_DEPTH, 'depth');
});
it('stays silent about depth when the tree ends inside the cap', () => {
const onTruncated = vi.fn();
collectDescendants(1, chain(4), { onTruncated });
expect(onTruncated).not.toHaveBeenCalled();
});
it('caps the node count and reports the truncation', () => {
// One parent with far more children than the cap allows.
const wide = new Map<number, number[]>([[1, Array.from({ length: PROC_WALK_MAX_NODES * 3 }, (_, i) => i + 2)]]);
const onTruncated = vi.fn();
const out = collectDescendants(1, wide, { onTruncated });
expect(out).toHaveLength(PROC_WALK_MAX_NODES);
expect(onTruncated).toHaveBeenCalledWith(1, PROC_WALK_MAX_NODES, 'nodes');
});
it('stays silent when nothing was truncated', () => {
const onTruncated = vi.fn();
collectDescendants(1, tree([[1, 2]]), { onTruncated });
expect(onTruncated).not.toHaveBeenCalled();
});
it('survives the shape that caused the incident: many wide, deep trees', () => {
// 28 adopted trees, branching 4-wide. Depth 6 already gives 4096 nodes per tree —
// eight times the cap, which is what this asserts. (The real incident's trees were
// deeper still; building that here would mean materialising 16M map entries and
// would only test the fixture builder.)
const t = new Map<number, number[]>();
let next = 1000;
const roots: number[] = [];
for (let r = 0; r < 28; r += 1) {
const root = next++;
roots.push(root);
let frontier = [root];
for (let d = 0; d < 6; d += 1) {
const nf: number[] = [];
for (const p of frontier) {
const kids = [next++, next++, next++, next++];
t.set(p, kids);
nf.push(...kids);
}
frontier = nf;
}
}
for (const root of roots) {
const out = collectDescendants(root, t);
expect(out.length).toBeLessThanOrEqual(PROC_WALK_MAX_NODES);
}
});
it('honours explicit overrides', () => {
expect(collectDescendants(1, chain(40), { maxDepth: 3 })).toEqual([2, 3, 4]);
expect(collectDescendants(1, chain(40), { maxNodes: 2 })).toEqual([2, 3]);
});
it('returns nothing for an unknown pid or an empty snapshot', () => {
expect(collectDescendants(999, tree([[1, 2]]))).toEqual([]);
expect(collectDescendants(1, new Map())).toEqual([]);
});
});
/**
* The bound must be reachable through the code that actually kills things.
*
* The unit tests above exercise `collectDescendants` directly, which is necessary but
* not sufficient: reverting `tmux-manager.ts` to the old unbounded `pgrep -P` recursion
* left every one of them green. This asserts the wiring — that TmuxManager's descendant
* lookup goes through the bounded walk and honours its caps.
*
* The snapshot refresh is stubbed. Without that the manager runs a real `ps` and
* replaces the fixture, and the test silently measures the machine's own process tree
* instead of the tree under test — which is how the first version of this test passed
* even with both caps bypassed.
*/
describe('TmuxManager uses the bounded walk', () => {
/** Build a tree `width` wide and `depth` deep, rooted at 1. */
function bigTree(width: number, depth: number): Map<number, number[]> {
const t = new Map<number, number[]>();
let next = 2;
let frontier = [1];
for (let d = 0; d < depth; d += 1) {
const nf: number[] = [];
for (const p of frontier) {
const kids = Array.from({ length: width }, () => next++);
t.set(p, kids);
nf.push(...kids);
}
frontier = nf;
}
return t;
}
async function walkVia(fixture: Map<number, number[]>): Promise<number[]> {
const { TmuxManager } = await import('../src/tmux-manager.js');
const Klass = TmuxManager as unknown as {
refreshProcSnapshot(): Promise<Map<number, number[]>>;
procSnapshot: unknown;
};
const original = Klass.refreshProcSnapshot;
Klass.refreshProcSnapshot = () => Promise.resolve(fixture);
Klass.procSnapshot = { at: Date.now(), byParent: fixture };
try {
const mgr = new TmuxManager();
return await (mgr as unknown as { getChildPidsFresh(pid: number): Promise<number[]> }).getChildPidsFresh(1);
} finally {
Klass.refreshProcSnapshot = original;
Klass.procSnapshot = null;
}
}
it('honours the node cap on a tree far wider than it', async () => {
// 4096 descendants available; the cap is 500. With the caps bypassed — the shape
// a regression at the call site would take — this returns thousands.
const out = await walkVia(bigTree(4, 6));
expect(out.length).toBe(PROC_WALK_MAX_NODES);
});
it('honours the depth cap on a deep chain', async () => {
const chainTree = new Map<number, number[]>();
for (let i = 1; i < 40; i += 1) chainTree.set(i, [i + 1]);
const out = await walkVia(chainTree);
expect(out.length).toBe(PROC_WALK_MAX_DEPTH);
});
it('spawns nothing per node — the walk only reads the snapshot', async () => {
// A per-node spawn against this fixture would mean thousands of processes; the
// test completing at all is the assertion, plus the bound holding.
const out = await walkVia(bigTree(4, 6));
expect(out.length).toBeLessThanOrEqual(PROC_WALK_MAX_NODES);
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview A lost input must stay retryable.
*
* POST /api/sessions/:id/input answers 200 BEFORE the write is attempted — the mux
* write is fire-and-forget so the HTTP response never waits on a tmux child. The
* dedup bookkeeping, however, recorded the (clientId, seq) pair as applied at that
* same moment. A write that then failed left the client with a 200, no message in
* the pane, and a seq the server would reject as a duplicate on retry: the input was
* unrecoverable by the very mechanism meant to make delivery reliable.
*
* Observed in the wild: a prompt shown as sent in a chat client, a 200 in the proxy
* log, and an empty prompt line in the pane.
*/
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Session } from '../../src/session.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
async function createEnvelopeHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
type Internals = { _appliedInputSeq: Map<string, number> };
const seqOf = (s: Session, client: string) => (s as unknown as Internals)._appliedInputSeq.get(client);
describe('input dedup bookkeeping', () => {
const make = () => new Session({ workingDir: '/tmp', mode: 'claude' });
it('accepts an increasing seq once and rejects the replay', () => {
const s = make();
expect(s.shouldApplyInput('c1', 1)).toBe(true);
expect(s.shouldApplyInput('c1', 1)).toBe(false);
expect(s.shouldApplyInput('c1', 2)).toBe(true);
});
it('forgetInputSeq re-opens a failed delivery for retry', () => {
const s = make();
expect(s.shouldApplyInput('c1', 7)).toBe(true);
s.forgetInputSeq('c1', 7); // the write failed after the 200 went out
expect(s.shouldApplyInput('c1', 7)).toBe(true);
});
it('does not re-open a seq that a later input has superseded', () => {
// Rolling back blindly would let an old, already-superseded message replay.
const s = make();
s.shouldApplyInput('c1', 7);
s.shouldApplyInput('c1', 8);
s.forgetInputSeq('c1', 7);
expect(s.shouldApplyInput('c1', 8)).toBe(false);
expect(seqOf(s, 'c1')).toBe(8);
});
it('is scoped per client', () => {
const s = make();
s.shouldApplyInput('c1', 5);
s.forgetInputSeq('c2', 5);
expect(s.shouldApplyInput('c1', 5)).toBe(false);
});
it('tolerates a rollback for a client that was never seen', () => {
const s = make();
expect(() => s.forgetInputSeq('ghost', 3)).not.toThrow();
});
});
describe('Session.write delivery signal', () => {
it('reports false when there is no PTY instead of swallowing the data', () => {
// The silent swallow was the third way input could vanish: no PTY, no error,
// no return value — the caller had no way to know.
const s = new Session({ workingDir: '/tmp', mode: 'claude' });
expect(s.write('hello\r')).toBe(false);
});
});
/**
* Wiring, not primitives.
*
* The first version of this file tested Session directly and nothing else: reverting
* the route to master — deleting the rollback call, the load-bearing half of the fix —
* left all six tests green. These drive the actual HTTP route.
*/
describe('POST /api/sessions/:id/input rollback wiring', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
beforeEach(async () => {
harness = await createEnvelopeHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (body: Record<string, unknown>, id = 'test-session-1') =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/input`, payload: body });
it('rolls the seq back when both the mux write and the direct write fail', async () => {
const session = harness.ctx.sessions.get('test-session-1')!;
session.failWrites = true; // writeViaMux false AND write() false
await post({ input: 'lost\r', useMux: true, clientId: 'c1', seq: 1 });
await new Promise((r) => setTimeout(r, 20)); // the mux write is fire-and-forget
// The retry the client would make must be accepted, not swallowed as a duplicate.
expect(session.shouldApplyInput('c1', 1)).toBe(true);
});
it('keeps the seq burnt when delivery succeeded', async () => {
const session = harness.ctx.sessions.get('test-session-1')!;
await post({ input: 'fine\r', useMux: true, clientId: 'c1', seq: 1 });
await new Promise((r) => setTimeout(r, 20));
expect(session.shouldApplyInput('c1', 1)).toBe(false);
});
it('rolls the seq back on the non-mux path too', async () => {
// Still a 200: a session may legitimately have no PTY yet, and turning that
// into a failure status would be a contract change. Re-opening the seq is not.
const session = harness.ctx.sessions.get('test-session-1')!;
session.failWrites = true;
const res = await post({ input: 'x\r', useMux: false, clientId: 'c2', seq: 5 });
expect(res.statusCode).toBe(200);
expect(session.shouldApplyInput('c2', 5)).toBe(true);
});
});
+49
View File
@@ -208,6 +208,55 @@ describe('ws-routes', () => {
}
});
it('ACKs a delivered input and burns its seq', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'i', d: 'ok\r', cid: 'c1', seq: 1 }));
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 1 });
expect(session.shouldApplyInput('c1', 1)).toBe(false);
} finally {
ws.close();
}
});
it('withholds the ACK and re-opens the seq when the write did not land', async () => {
// A session whose PTY is gone swallows the write. ACKing anyway told the
// client to drop the frame from its durable queue while the seq stayed
// burnt, so the retry that reliable delivery exists for was rejected as a
// duplicate — the input was lost for good.
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
session.failWrites = true;
ws.send(JSON.stringify({ t: 'i', d: 'lost\r', cid: 'c1', seq: 1 }));
await expect(nextMessage(ws, 600)).rejects.toThrow(/timeout/);
expect(session.shouldApplyInput('c1', 1)).toBe(true);
} finally {
ws.close();
}
});
it('still ACKs a duplicate frame the server deliberately skipped', async () => {
// Dedup must stay silent-but-acknowledged: the client has to be able to
// drop a frame it already delivered once.
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
session.shouldApplyInput('c1', 7); // pretend seq 7 already landed
ws.send(JSON.stringify({ t: 'i', d: 'again\r', cid: 'c1', seq: 7 }));
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 7 });
expect(session.writeBuffer).not.toContain('again\r');
} finally {
ws.close();
}
});
it('ignores input exceeding MAX_INPUT_LENGTH', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
+89
View File
@@ -0,0 +1,89 @@
/**
* @fileoverview `/api/events` must not lose the headers the security hook set.
*
* The SSE route answers with `reply.raw.writeHead()`, which writes straight to the
* Node response and bypasses Fastify's header store. Everything the `onRequest`
* security hook had granted was therefore dropped — including the
* `Access-Control-Allow-Origin` it emits for localhost origins. The contradiction is
* visible from a browser: a localhost page may call every other `/api` endpoint
* cross-origin, but its EventSource fails CORS.
*
* These tests drive a REAL WebServer. An earlier version asserted against an inline
* copy of the hook and the handler, which proved nothing: reverting the fix in
* `server.ts` left every test green.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3119;
const LOCAL_ORIGIN = 'http://localhost:5173';
/** Open /api/events, read the response headers, then abort — it never ends on its own. */
async function eventsHeaders(baseUrl: string, origin?: string): Promise<Headers> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
headers: origin ? { Origin: origin } : undefined,
});
const headers = res.headers;
controller.abort(); // stop consuming the stream
return headers;
} finally {
clearTimeout(timeout);
}
}
describe('GET /api/events header inheritance', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
});
afterAll(async () => {
await server.stop();
}, 60000);
it('keeps the CORS header the security hook granted a localhost origin', async () => {
// The regression: this header is set on the Fastify reply and was then thrown
// away by writeHead, so an EventSource from a localhost dev server failed CORS
// while every other endpoint worked.
const headers = await eventsHeaders(baseUrl, LOCAL_ORIGIN);
expect(headers.get('access-control-allow-origin')).toBe(LOCAL_ORIGIN);
});
it('keeps the security headers the hook set', async () => {
const headers = await eventsHeaders(baseUrl);
expect(headers.get('x-content-type-options')).toBe('nosniff');
expect(headers.get('x-frame-options')).toBe('SAMEORIGIN');
expect(headers.get('content-security-policy')).toBeTruthy();
});
it('still sets the SSE headers, and they win over anything inherited', async () => {
const headers = await eventsHeaders(baseUrl);
expect(headers.get('content-type')).toBe('text/event-stream');
expect(headers.get('cache-control')).toBe('no-cache');
expect(headers.get('x-accel-buffering')).toBe('no');
});
it('grants nothing to a non-localhost origin — the hook decides, not this route', async () => {
const headers = await eventsHeaders(baseUrl, 'https://evil.example');
expect(headers.get('access-control-allow-origin')).toBeNull();
});
it('matches what a normal JSON endpoint returns for the same origin', async () => {
// The point of the fix: /api/events stops being the odd one out.
const json = await fetch(`${baseUrl}/api/status`, { headers: { Origin: LOCAL_ORIGIN } });
const sse = await eventsHeaders(baseUrl, LOCAL_ORIGIN);
expect(sse.get('access-control-allow-origin')).toBe(json.headers.get('access-control-allow-origin'));
expect(sse.get('x-content-type-options')).toBe(json.headers.get('x-content-type-options'));
});
});
+240
View File
@@ -0,0 +1,240 @@
/**
* Issue #205, round 2: the 1.12.0 retest still reported unusable scrollback —
* a completely dead wheel on Firefox/macOS (while Fn+Up paged back through
* intact text), and history on iPhone that went back a little, repeated blocks
* and got worse the further up it went.
*
* Both signatures come from a Claude pane's LOCAL buffer being hollow. tmux
* keeps no history for a repaint-mode pane (`history_size≈0`), so:
* - any gesture routed to local scrollback scrolls nothing, and
* - the scroll-to-top `?full=1` re-pull replaces a multi-frame buffer with a
* single captured frame, deleting history mid-scroll.
*
* These cover the two guards that fix it: `_replayWouldShrinkBuffer` (refuse a
* downgrading re-pull) and `_maybePageCliTranscript` (page the CLI's own
* transcript when there is nothing local to scroll), plus the diagnostic that
* makes the routing decision visible instead of guessable.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness() {
const CodemanApp = function CodemanApp(this: any) {};
const logs: string[] = [];
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => true },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
return { app: new (CodemanApp as any)(), logs };
}
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number } = {}) {
const { app, logs } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion }]]);
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
rows: overrides.rows ?? 36,
modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } },
};
return { app, sent, logs };
}
describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
it('estimates replayed rows from wrapped, escape-laden capture text', () => {
const { app } = loadTerminalUiHarness();
expect(app._estimateReplayRows('a\r\nb\r\nc', 80)).toBe(3);
// SGR colour runs occupy no cells, so they must not inflate the estimate.
expect(app._estimateReplayRows('\x1b[38;5;196mred\x1b[0m\r\nplain', 80)).toBe(2);
// capture-pane -J joins wrapped rows, so a long logical line re-wraps on
// write — counting newlines alone would undershoot by 2 rows here.
expect(app._estimateReplayRows('x'.repeat(25), 10)).toBe(3);
expect(app._estimateReplayRows('', 80)).toBe(0);
expect(app._estimateReplayRows(undefined, 80)).toBe(0);
});
it('refuses a capture that would leave LESS history than the terminal holds', () => {
const { app } = loadTerminalUiHarness();
app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 300 } } };
// Claude pane: tmux has no history, so the capture is one frame while xterm
// holds hundreds of replayed rows. Rewriting would delete them mid-scroll.
const oneFrame = Array.from({ length: 36 }, (_, i) => `frame line ${i}`).join('\r\n');
expect(app._replayWouldShrinkBuffer(oneFrame)).toBe(true);
// Shell pane after a burst/tab-switch collapse: tmux really does hold more.
const realHistory = Array.from({ length: 800 }, (_, i) => `history ${i}`).join('\r\n');
expect(app._replayWouldShrinkBuffer(realHistory)).toBe(false);
});
it('tolerates a one-screen shortfall so ordinary recoveries still replay', () => {
const { app } = loadTerminalUiHarness();
// buffer.active.length counts the blank rows under the last line and the row
// estimate can only approximate wrapping, so a near-tie must NOT read as a
// downgrade — only a capture worse by more than a full screen does.
app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 120 } } };
expect(app._replayWouldShrinkBuffer(Array.from({ length: 100 }, () => 'x').join('\r\n'))).toBe(false);
expect(app._replayWouldShrinkBuffer(Array.from({ length: 40 }, () => 'x').join('\r\n'))).toBe(true);
});
it('never refuses when the terminal has no buffer to protect', () => {
const { app } = loadTerminalUiHarness();
app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 0 } } };
expect(app._replayWouldShrinkBuffer('anything')).toBe(false);
});
it('is wired into _maybeRefetchFullHistory BEFORE the destructive reset', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = source.indexOf('async _maybeRefetchFullHistory()');
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
const reset = source.indexOf('this._resetTerminalForReplay()', start);
expect(start).toBeGreaterThan(-1);
expect(guard).toBeGreaterThan(start);
expect(guard).toBeLessThan(reset); // refuse first, only then reset+rewrite
// A hollow pane must also stop re-fetching megabytes on every scroll-up.
expect(source).toContain('this._fullHistoryRepullUseless');
expect(source).toContain('this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000');
});
});
describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => {
it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => {
const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false
// Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp.
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
// Downward travel pages back toward the live screen.
app._maybePageCliTranscript({ shiftKey: false }, 18);
app._flushWheelSgrQueue();
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
});
it('accumulates sub-page travel instead of dropping or over-sending it', () => {
const { app, sent } = hollowClaudeApp();
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed…
app._flushWheelSgrQueue();
expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet
app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
});
it('caps the keys one gesture batch can emit', () => {
const { app, sent } = hollowClaudeApp();
app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
});
it('leaves every session that has real local scrollback alone', () => {
const { app } = hollowClaudeApp();
// Shift is the explicit "give me local scrollback" gesture — never paged.
expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false);
// A buffer with history scrolls locally, as before.
app.terminal.buffer.active.baseY = 120;
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.terminal.buffer.active.baseY = 0;
// Non-Claude modes keep their existing behavior (shell scrolls tmux history
// through the alt-screen strip; codex/gemini page keys are unverified).
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
// An alternate-screen pane belongs to xterm's own alt-scroll handling.
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal.buffer.active.type = 'alternate';
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
});
it('rescues the local-scrollback opt-out footgun instead of silently dying', () => {
// "Wheel scrolls local history" ON pins the wheel to a buffer that, for a
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223' }); // gate would forward…
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
});
it('drops travel accumulated on another tab', () => {
const { app, sent } = hollowClaudeApp();
app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page
app.activeSessionId = 'sess-2';
app.sessions.set('sess-2', { mode: 'claude' });
app._maybePageCliTranscript({ shiftKey: false }, -1); // must not complete sess-1's page
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('is reachable from both the wheel and the touch paths', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
// Wheel: after the forwarding gate, before the local smooth scroll.
expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;');
// Touch: touchmove and the momentum loop both fall through to it.
expect(source.match(/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false \}, lines\)\)/g)).toHaveLength(2);
});
});
describe('scroll routing diagnostic (issue #205 round 2)', () => {
it('prints the decision and its inputs once per session, and again when it changes', () => {
const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
app._logScrollRouting('local-scrollback');
app._logScrollRouting('local-scrollback'); // same decision → stays quiet
expect(logs).toHaveLength(1);
expect(logs[0]).toContain('sess-1 → local-scrollback');
expect(logs[0]).toContain('mode=claude');
expect(logs[0]).toContain('cliVersion=2.1.100');
expect(logs[0]).toContain('localScrollbackOptOut=false');
expect(logs[0]).toContain('mouseTracking=none');
app._logScrollRouting('page-keys'); // a changed route still prints
expect(logs).toHaveLength(2);
expect(logs[1]).toContain('page-keys');
});
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed
app._logScrollRouting('page-keys');
expect(logs[0]).toContain('cliVersion=unknown');
});
});