mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a922d301b1 | ||
|
|
abca552676 | ||
|
|
12a996b107 | ||
|
|
458e751a33 | ||
|
|
dab432b3fd | ||
|
|
79a0399552 | ||
|
|
61251c0b94 | ||
|
|
bb4ba79791 | ||
|
|
d7ad73bc9b | ||
|
|
96ee8b536d | ||
|
|
b7f3b07c79 | ||
|
|
2073a1b185 | ||
|
|
f9a8493823 | ||
|
|
d2711ef092 | ||
|
|
30a15adbd6 | ||
|
|
a35438ba34 | ||
|
|
fef903df98 | ||
|
|
02e7d3fcba | ||
|
|
f744719650 | ||
|
|
63c5ba89da | ||
|
|
7fc4784d0f |
@@ -1,5 +1,36 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.20.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Terminal input and scrollback fixes (PRs #327, #331):
|
||||||
|
- IME punctuation preserved (#327): keyCode 229 / `Process` key events are now delegated to xterm's CompositionHelper instead of being suppressed, so an active Chinese IME committing numbers and full-width punctuation (,。!? and friends) reaches the terminal correctly. The CJK input field sends the browser's committed text instead of guessing from `KeyboardEvent.key`, and the redundant Android orphan-input fallback is removed so xterm is the single input owner.
|
||||||
|
- Shell history replay bounded (#331): selecting a Shell session loads a bounded 1 MiB tail instead of replaying the entire multi-megabyte tmux scrollback on xterm's main thread; full history stays available via the explicit "Load full history" action. tmux history limits now apply correctly on both legacy tmux (global default set in the same command queue before pane creation) and tmux 3.7+ (per-pane targeting that never resizes or trims unrelated live panes). Also adds `Server-Timing` and `[TERMINAL-PERF]` timing stages for terminal loads, fixes `scrollToLastNonEmptyLine` double-counting scrollback rows, and keeps live output ordered behind snapshot replays.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- @dignfei for both fixes: the IME punctuation root-cause fix (#327) and the bounded shell history replay with the tmux history-limit correctness work (#331).
|
||||||
|
|
||||||
|
## 1.20.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Response viewer for OpenCode, Gemini, Antigravity and Pi sessions (#326). External CLIs render their own TUIs, so the viewer used to come up empty for them; a new transcript parser (`response-viewer-transcript.ts`) reconstructs the conversation from the pane text instead, and the `?context=full` view now tags every block with a role so prompts render as "You" and agent output as the assistant. The divider normalizer was rewritten as a linear scan after review found catastrophic backtracking on agent-controlled input (minutes of stall on a long dash run), with an equivalence corpus pinning the old accept set.
|
||||||
|
|
||||||
|
CLIs installed via nvm or Homebrew are now found when Codeman runs as a service (#329). A shared resolver falls back to a login-shell probe when the direct PATH lookup misses, so systemd and LaunchAgent installs no longer report every CLI as missing. Review hardening on top: a failed resolution is negative-cached with doubling backoff instead of re-spawning a login shell on every request, all probes pass `killSignal: 'SIGKILL'` (interactive bash shrugs off SIGTERM, and a blocking `.bash_profile` could have hung the server indefinitely), the resolvers are inert under vitest again so test suites cannot execute binaries found on the dev box, and the improved not-found guidance is wired into both the session-create errors and the per-CLI status endpoints.
|
||||||
|
|
||||||
|
`GET /api/system/repo-status` reports branch, upstream, ahead/behind and remote reachability for git-clone installs (#328). Review hardening: the git network calls moved off the synchronous path onto a single-flight 45s cache (one slow remote could previously freeze the whole server for up to a minute per request), remote URLs and git stderr are credential-redacted before they leave the server, the spawns use the same non-interactive git env as the clone path, and a local-branch upstream no longer parses into garbage.
|
||||||
|
|
||||||
|
Auto Copy for the terminal (#325, opt-in, per-device): a finished selection (mouse drag, double or triple click, or a phone long-press) lands on the clipboard by itself, so select-then-copy becomes select. Alongside it, hand-encoded tap reports are now gated on the server-observed `cliMouseTracking` state, so a pane that has fallen back to a plain shell no longer receives `[<0;88;20M` junk on tap.
|
||||||
|
|
||||||
|
The Ralph loop no longer stops polling after two ticks (#330): the reschedule guard read a stale timer handle that the timer callback never cleared, so the loop silently died while its status stayed `running`. The handle is now nulled as the callback's first statement, and a regression test pins the bug.
|
||||||
|
|
||||||
|
The red "needs you" tab alert clears when a dialog is answered in the terminal instead of surviving until the end of the turn: the post-hook re-capture could erase the parsed dialog options that the staleness sweep relies on (`applyCapture` is now add-only for options), and a delayed staleness pass now runs while a page is open. The unreachable `copyTerminal()` was removed, closing out #322.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- @aakhter contributed the external-CLI response viewer (#326), the repo-status endpoint (#328), the login-shell CLI resolution (#329) and the Ralph reschedule fix (#330)
|
||||||
|
- @rounakdatta reported the mobile copy gap (#322) closed out in this release
|
||||||
|
|
||||||
## 1.19.7
|
## 1.19.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
### Full-scrollback replay
|
### 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. ⚠️ **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`.
|
**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 non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is therefore explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The 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
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||||
|
|
||||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||||
|
|
||||||
**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 (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`.
|
**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 (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`.
|
||||||
|
|
||||||
@@ -377,7 +377,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
|||||||
|
|
||||||
### Buffers, uploads, and terminal history
|
### Buffers, uploads, and terminal history
|
||||||
|
|
||||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||||
|
|
||||||
## Local packages and build artifacts
|
## Local packages and build artifacts
|
||||||
|
|
||||||
|
|||||||
@@ -133,8 +133,10 @@ TUIs render correctly.
|
|||||||
|
|
||||||
Worth knowing:
|
Worth knowing:
|
||||||
|
|
||||||
- **Scrollback.** The first time you open a session, Codeman pulls the entire tmux
|
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||||
scrollback, not just the recent tail. Scrolling to the very top pulls again on demand.
|
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||||
|
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||||
|
stays within the bounded browser buffer so dragging upward remains responsive.
|
||||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||||
always local scrollback. Other CLIs scroll locally.
|
always local scrollback. Other CLIs scroll locally.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.7",
|
"version": "1.20.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.7",
|
"version": "1.20.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.7",
|
"version": "1.20.1",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
|
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
|
||||||
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
|
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
|
||||||
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
|
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
|
||||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live.
|
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is wired.
|
||||||
|
* tmux <3.7 applies it to new panes; tmux 3.7+ can also resize live panes.
|
||||||
* All values remain env- and settings-overridable and bounds-clamped via
|
* All values remain env- and settings-overridable and bounds-clamped via
|
||||||
* resolveTerminalHistoryConfig().
|
* resolveTerminalHistoryConfig().
|
||||||
*/
|
*/
|
||||||
|
|||||||
+11
-2
@@ -479,14 +479,23 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
|
|||||||
|
|
||||||
// ─── Pure: output handling ───────────────────────────────────────────────────
|
// ─── Pure: output handling ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact any `scheme://user:secret@host` credential pair embedded in text — a
|
||||||
|
* remote URL stored with an inline token, or git stderr echoing such a URL
|
||||||
|
* back. Shared by the clone error path (`sanitizeGitOutput`) and the
|
||||||
|
* repository-status card fields (`web/repo-status.ts`).
|
||||||
|
*/
|
||||||
|
export function redactGitCredentials(text: string): string {
|
||||||
|
return text.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
|
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
|
||||||
* redact any `scheme://user:secret@host` that a credential helper echoed back,
|
* redact any `scheme://user:secret@host` that a credential helper echoed back,
|
||||||
* and keep only the tail (the last lines are the ones that say why it failed).
|
* and keep only the tail (the last lines are the ones that say why it failed).
|
||||||
*/
|
*/
|
||||||
export function sanitizeGitOutput(text: string, maxBytes = MAX_STDERR_BYTES): string {
|
export function sanitizeGitOutput(text: string, maxBytes = MAX_STDERR_BYTES): string {
|
||||||
const redacted = text
|
const redacted = redactGitCredentials(text)
|
||||||
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@')
|
|
||||||
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
|
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
|
||||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export interface CreateSessionOptions {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** tmux history-limit (scrollback lines) to set for this session. */
|
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||||
remote?: SessionRemote;
|
remote?: SessionRemote;
|
||||||
@@ -116,7 +116,7 @@ export interface RespawnPaneOptions {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||||
remote?: SessionRemote;
|
remote?: SessionRemote;
|
||||||
@@ -216,7 +216,7 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
/** Update Ralph enabled state for a session */
|
/** Update Ralph enabled state for a session */
|
||||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||||
|
|
||||||
/** Apply a tmux history-limit to all tracked sessions. */
|
/** Apply history-limit to live panes where tmux supports it, otherwise to future panes. */
|
||||||
setHistoryLimit(limit: number): Promise<void>;
|
setHistoryLimit(limit: number): Promise<void>;
|
||||||
|
|
||||||
// ========== Discovery ==========
|
// ========== Discovery ==========
|
||||||
|
|||||||
+8
-1
@@ -281,7 +281,14 @@ export class RalphLoop extends EventEmitter {
|
|||||||
// Guard: only reschedule if still running AND no timer is pending
|
// Guard: only reschedule if still running AND no timer is pending
|
||||||
// (prevents race where stop() clears timer between our check and setTimeout)
|
// (prevents race where stop() clears timer between our check and setTimeout)
|
||||||
if (this._status === 'running' && this.loopTimer === null) {
|
if (this._status === 'running' && this.loopTimer === null) {
|
||||||
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
|
// Null the handle when the timer fires, BEFORE re-entering runLoop —
|
||||||
|
// otherwise the `loopTimer === null` guard above stays false on the
|
||||||
|
// next pass and the loop stops rescheduling after 2 ticks.
|
||||||
|
// Mirrors the orchestrator-loop reschedule pattern.
|
||||||
|
this.loopTimer = setTimeout(() => {
|
||||||
|
this.loopTimer = null;
|
||||||
|
this.runLoop();
|
||||||
|
}, this.pollIntervalMs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -519,7 +519,7 @@ export class Session extends EventEmitter {
|
|||||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||||
private _effort: EffortLevel | undefined;
|
private _effort: EffortLevel | undefined;
|
||||||
|
|
||||||
// tmux history-limit (scrollback lines) applied to this session's pane.
|
// tmux history-limit (scrollback lines) allocated when this session's pane is created.
|
||||||
private readonly _tmuxHistoryLimit: number;
|
private readonly _tmuxHistoryLimit: number;
|
||||||
|
|
||||||
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||||
@@ -609,7 +609,7 @@ export class Session extends EventEmitter {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** tmux history-limit (scrollback lines) for this session's pane. */
|
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
|
||||||
tmuxHistoryLimit?: number;
|
tmuxHistoryLimit?: number;
|
||||||
/** Restored per-session attachment history. May include server-private external paths. */
|
/** Restored per-session attachment history. May include server-private external paths. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
|
|||||||
+67
-42
@@ -75,11 +75,17 @@ import {
|
|||||||
SAFE_PATH_PATTERN,
|
SAFE_PATH_PATTERN,
|
||||||
findClaudeDir,
|
findClaudeDir,
|
||||||
getClaudeCliVersion,
|
getClaudeCliVersion,
|
||||||
|
getClaudeNotFoundMessage,
|
||||||
resolveOpenCodeDir,
|
resolveOpenCodeDir,
|
||||||
|
getOpenCodeNotFoundMessage,
|
||||||
resolveCodexDir,
|
resolveCodexDir,
|
||||||
|
getCodexNotFoundMessage,
|
||||||
resolveGeminiDir,
|
resolveGeminiDir,
|
||||||
|
getGeminiNotFoundMessage,
|
||||||
resolveAntigravityDir,
|
resolveAntigravityDir,
|
||||||
|
getAntigravityNotFoundMessage,
|
||||||
resolvePiDir,
|
resolvePiDir,
|
||||||
|
getPiNotFoundMessage,
|
||||||
resolveLocalShell,
|
resolveLocalShell,
|
||||||
loginShellArgs,
|
loginShellArgs,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
@@ -1562,6 +1568,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
private reconnectGuard: Set<string> = new Set();
|
private reconnectGuard: Set<string> = new Set();
|
||||||
|
|
||||||
private trueColorConfigured = false;
|
private trueColorConfigured = false;
|
||||||
|
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
|
||||||
|
private liveHistoryResizeSupported: boolean | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -1580,6 +1588,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return tmuxCommand(this.tmuxSocket);
|
return tmuxCommand(this.tmuxSocket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private supportsLiveHistoryResize(): boolean {
|
||||||
|
if (this.liveHistoryResizeSupported !== null) return this.liveHistoryResizeSupported;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execSync(`${this.tmux()} -V`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
const match = output.match(/(?:^|\D)(\d+)\.(\d+)/);
|
||||||
|
const major = match ? Number(match[1]) : 0;
|
||||||
|
const minor = match ? Number(match[2]) : 0;
|
||||||
|
this.liveHistoryResizeSupported = major > 3 || (major === 3 && minor >= 7);
|
||||||
|
} catch {
|
||||||
|
// Unknown versions take the legacy path required by tmux <3.7.
|
||||||
|
this.liveHistoryResizeSupported = false;
|
||||||
|
}
|
||||||
|
return this.liveHistoryResizeSupported;
|
||||||
|
}
|
||||||
|
|
||||||
// Load saved sessions from disk (NEVER called in test mode)
|
// Load saved sessions from disk (NEVER called in test mode)
|
||||||
private loadSessions(): void {
|
private loadSessions(): void {
|
||||||
if (IS_TEST_MODE) return;
|
if (IS_TEST_MODE) return;
|
||||||
@@ -1853,29 +1881,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve CLI binary directory based on mode
|
// Resolve CLI binary directory based on mode. The not-found messages come
|
||||||
|
// from the resolvers (formatCliNotFoundMessage) so the error names WHERE it
|
||||||
|
// looked — server PATH, login shell, checked directories — instead of just
|
||||||
|
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
|
||||||
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||||||
if (mode === 'claude' && !cliDir) {
|
if (mode === 'claude' && !cliDir) {
|
||||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
throw new Error(getClaudeNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'opencode' && !cliDir) {
|
if (mode === 'opencode' && !cliDir) {
|
||||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
throw new Error(getOpenCodeNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'codex' && !cliDir) {
|
if (mode === 'codex' && !cliDir) {
|
||||||
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
throw new Error(getCodexNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'gemini' && !cliDir) {
|
if (mode === 'gemini' && !cliDir) {
|
||||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
throw new Error(getGeminiNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'antigravity' && !cliDir) {
|
if (mode === 'antigravity' && !cliDir) {
|
||||||
throw new Error(
|
throw new Error(getAntigravityNotFoundMessage());
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (mode === 'pi' && !cliDir) {
|
if (mode === 'pi' && !cliDir) {
|
||||||
throw new Error(
|
throw new Error(getPiNotFoundMessage());
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||||
@@ -1921,7 +1948,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
||||||
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
||||||
// pane cd's into workingDir below via respawn-pane.
|
// pane cd's into workingDir below via respawn-pane.
|
||||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
// tmux <3.7 allocates history only at pane creation, so its global default
|
||||||
|
// must be set immediately BEFORE new-session. tmux 3.7+ can resize a pane
|
||||||
|
// after creation; target only the new session there because changing the
|
||||||
|
// global option can resize (and when lowered, trim) unrelated live panes.
|
||||||
|
const safeHistoryLimit =
|
||||||
|
Number.isSafeInteger(historyLimit) && historyLimit > 0 ? Math.trunc(historyLimit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
|
const createSessionCommand = this.supportsLiveHistoryResize()
|
||||||
|
? `${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`
|
||||||
|
: `${this.tmux()} set-option -g history-limit ${safeHistoryLimit} \\; new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`;
|
||||||
|
execSync(createSessionCommand, {
|
||||||
cwd: TMUX_LAUNCH_CWD,
|
cwd: TMUX_LAUNCH_CWD,
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
@@ -1986,16 +2022,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* Already set globally as fallback */
|
/* Already set globally as fallback */
|
||||||
}),
|
}),
|
||||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
|
||||||
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
|
|
||||||
// in constants.js), which stays lower to protect browser/mobile memory.
|
|
||||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
})
|
|
||||||
.then(() => {})
|
|
||||||
.catch(() => {
|
|
||||||
/* Non-critical — falls back to tmux default */
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||||
@@ -2119,7 +2145,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
name,
|
name,
|
||||||
@@ -2130,16 +2155,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||||
|
|
||||||
// Re-apply the configured tmux history-limit after respawn (kept in sync
|
|
||||||
// with the live setting via setHistoryLimit()).
|
|
||||||
if (!IS_TEST_MODE) {
|
|
||||||
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
}).catch(() => {
|
|
||||||
/* Non-critical — keeps existing tmux history-limit */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve CLI binary directory based on mode
|
// Resolve CLI binary directory based on mode
|
||||||
const { pathExport } = this.buildPathExport(mode);
|
const { pathExport } = this.buildPathExport(mode);
|
||||||
|
|
||||||
@@ -2998,9 +3013,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
|
* Apply a tmux history limit. tmux 3.7+ safely targets tracked live sessions;
|
||||||
* changes the terminal-history setting). Invalid limits fall back to the
|
* older releases can only change the global default for future panes. Invalid
|
||||||
* default. Best-effort per session.
|
* limits fall back to the default.
|
||||||
*/
|
*/
|
||||||
async setHistoryLimit(limit: number): Promise<void> {
|
async setHistoryLimit(limit: number): Promise<void> {
|
||||||
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
@@ -3009,12 +3024,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = Array.from(this.sessions.values()).map((session) =>
|
if (this.supportsLiveHistoryResize()) {
|
||||||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||||
})
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
})
|
||||||
await Promise.allSettled(updates);
|
);
|
||||||
|
await Promise.allSettled(updates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(`${this.tmux()} set-option -g history-limit ${safeLimit}`, {
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}).catch(() => {
|
||||||
|
// No tmux server yet is fine: legacy createSession sets the same default
|
||||||
|
// immediately before it creates the first pane.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -98,3 +98,52 @@ export interface UpdateCheckResult {
|
|||||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role of a remote in the repository-status view.
|
||||||
|
* - `tracking`: the current branch's `@{upstream}` remote (where `git pull` goes).
|
||||||
|
* - `upstream`: the canonical project (a remote named `origin`/`upstream` that is
|
||||||
|
* not the tracking remote).
|
||||||
|
* - `other`: anything else explicitly requested via `CODEMAN_UPDATE_REMOTES`.
|
||||||
|
*/
|
||||||
|
export type RepoRemoteRole = 'tracking' | 'upstream' | 'other';
|
||||||
|
|
||||||
|
/** A single incoming commit — present on the remote ref but not in local HEAD. */
|
||||||
|
export interface RepoIncomingCommit {
|
||||||
|
/** Abbreviated SHA. */
|
||||||
|
sha: string;
|
||||||
|
/** Commit subject (first line). */
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ahead/behind + incoming summary for local HEAD vs one remote's compare ref. */
|
||||||
|
export interface RepoRemoteStatus {
|
||||||
|
/** Remote name, e.g. `origin`, `bitbucket`. */
|
||||||
|
name: string;
|
||||||
|
/** Remote URL (best-effort; empty if unresolved). */
|
||||||
|
url: string;
|
||||||
|
role: RepoRemoteRole;
|
||||||
|
/** Ref HEAD is compared against, e.g. `origin/master`, `bitbucket/local`. */
|
||||||
|
compareRef: string;
|
||||||
|
/** Commits in local HEAD not on the remote ref (local-only / unpushed). */
|
||||||
|
ahead: number;
|
||||||
|
/** Commits on the remote ref not in local HEAD (incoming). */
|
||||||
|
behind: number;
|
||||||
|
/** Up to N most recent incoming commits (newest first). */
|
||||||
|
incoming: RepoIncomingCommit[];
|
||||||
|
/** Set when this remote could not be fetched/compared. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of the repository-status check across the configured remotes. */
|
||||||
|
export interface RepositoryStatusResult {
|
||||||
|
/** epoch ms of the check. */
|
||||||
|
checkedAt: number;
|
||||||
|
/** False when this is not a git install (then `remotes` is empty + `error` set). */
|
||||||
|
isGit: boolean;
|
||||||
|
/** Current running version, for display. */
|
||||||
|
currentVersion: string;
|
||||||
|
remotes: RepoRemoteStatus[];
|
||||||
|
/** Top-level error (e.g. not a git install, or no remotes resolved). */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,22 +7,37 @@
|
|||||||
* @module utils/antigravity-cli-resolver
|
* @module utils/antigravity-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import {
|
||||||
|
createCliExecutableResolver,
|
||||||
|
formatCliNotFoundMessage,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from './cli-executable-resolver.js';
|
||||||
|
|
||||||
/** Common directories where the Antigravity CLI binary may be installed */
|
/** Common directories where the Antigravity CLI binary may be installed */
|
||||||
const ANTIGRAVITY_SEARCH_DIRS = [
|
const ANTIGRAVITY_SEARCH_DIRS = [
|
||||||
join(homedir(), '.local', 'bin'),
|
join(homedir(), '.local', 'bin'),
|
||||||
join(homedir(), '.antigravity', 'bin'),
|
join(homedir(), '.antigravity', 'bin'),
|
||||||
'/usr/local/bin',
|
'/usr/local/bin',
|
||||||
|
join(homedir(), '.bun', 'bin'),
|
||||||
|
join(homedir(), '.npm-global', 'bin'),
|
||||||
join(homedir(), 'bin'),
|
join(homedir(), 'bin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the agy binary (empty string = searched but not found) */
|
const ANTIGRAVITY_NOT_FOUND =
|
||||||
let _antigravityDir: string | null = null;
|
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
|
||||||
|
|
||||||
|
function createAntigravityResolver(host?: CliResolverHost, now?: () => number) {
|
||||||
|
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */
|
||||||
|
export function createAntigravityResolverForTest(host: CliResolverHost, now?: () => number) {
|
||||||
|
return createAntigravityResolver(host, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const antigravityResolver = createAntigravityResolver();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `agy` binary.
|
* Finds the directory containing the `agy` binary.
|
||||||
@@ -31,30 +46,7 @@ let _antigravityDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveAntigravityDir(): string | null {
|
export function resolveAntigravityDir(): string | null {
|
||||||
if (_antigravityDir !== null) return _antigravityDir || null;
|
return antigravityResolver.resolve()?.directory ?? null;
|
||||||
|
|
||||||
try {
|
|
||||||
const result = execSync('which agy', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
}).trim();
|
|
||||||
if (result && existsSync(result)) {
|
|
||||||
_antigravityDir = dirname(result);
|
|
||||||
return _antigravityDir;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// agy not in PATH, will check common locations
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
|
|
||||||
if (existsSync(join(dir, 'agy'))) {
|
|
||||||
_antigravityDir = dir;
|
|
||||||
return _antigravityDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_antigravityDir = '';
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null {
|
|||||||
export function isAntigravityAvailable(): boolean {
|
export function isAntigravityAvailable(): boolean {
|
||||||
return resolveAntigravityDir() !== null;
|
return resolveAntigravityDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAntigravityNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
* @module utils/claude-cli-resolver
|
* @module utils/claude-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
import { delimiter, join } from 'node:path';
|
||||||
import { delimiter, dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||||
|
|
||||||
/** Common directories where the Claude CLI binary may be installed */
|
/** Common directories where the Claude CLI binary may be installed */
|
||||||
const CLAUDE_SEARCH_DIRS = [
|
const CLAUDE_SEARCH_DIRS = [
|
||||||
@@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'),
|
join(homedir(), 'bin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the claude binary (empty string = searched but not found) */
|
const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS });
|
||||||
let _claudeDir: string | null = null;
|
const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the Claude CLI binary can be located (via `which` or one of
|
* Returns true if the Claude CLI binary can be located (via `which` or one of
|
||||||
@@ -43,29 +43,11 @@ export function isClaudeAvailable(): boolean {
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function findClaudeDir(): string | null {
|
export function findClaudeDir(): string | null {
|
||||||
if (_claudeDir !== null) return _claudeDir || null;
|
return claudeResolver.resolve()?.directory ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Try `which` first (respects current PATH)
|
export function getClaudeNotFoundMessage(): string {
|
||||||
try {
|
return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics());
|
||||||
const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
|
||||||
if (result && existsSync(result)) {
|
|
||||||
_claudeDir = dirname(result);
|
|
||||||
return _claudeDir;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Claude not in PATH, will check common locations
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check common installation directories
|
|
||||||
for (const dir of CLAUDE_SEARCH_DIRS) {
|
|
||||||
if (existsSync(join(dir, 'claude'))) {
|
|
||||||
_claudeDir = dir;
|
|
||||||
return _claudeDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_claudeDir = ''; // mark as searched, not found
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +81,9 @@ export function getAugmentedPath(): string {
|
|||||||
const currentPath = process.env.PATH || '';
|
const currentPath = process.env.PATH || '';
|
||||||
const claudeDir = findClaudeDir();
|
const claudeDir = findClaudeDir();
|
||||||
|
|
||||||
if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) {
|
if (!claudeDir) return currentPath;
|
||||||
|
|
||||||
|
if (!currentPath.split(delimiter).includes(claudeDir)) {
|
||||||
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
||||||
return _augmentedPath;
|
return _augmentedPath;
|
||||||
}
|
}
|
||||||
@@ -193,6 +177,9 @@ function probeClaudeCliVersion(): string | null {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
env: { ...process.env, PATH: getAugmentedPath() },
|
env: { ...process.env, PATH: getAugmentedPath() },
|
||||||
|
// execFileSync's timeout only SENDS the signal and then keeps waiting; a
|
||||||
|
// child that ignores SIGTERM would block the server thread permanently.
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
});
|
});
|
||||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
|
||||||
|
*
|
||||||
|
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
|
||||||
|
* codex, gemini, antigravity, pi): the server process PATH first, then the
|
||||||
|
* CLI's common install directories in order, then — last, because it is the
|
||||||
|
* only step that spawns anything — an interactive login shell, which is what
|
||||||
|
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
|
||||||
|
* service with a minimal PATH (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||||
|
*
|
||||||
|
* Caching is asymmetric, same shape as `resolveClaudeCliVersion` in
|
||||||
|
* claude-cli-resolver.ts: a successful resolution is cached for the process
|
||||||
|
* lifetime, a MISS is negative-cached and retried only after a doubling backoff
|
||||||
|
* (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI
|
||||||
|
* status endpoints in system-routes.ts, the availability gates in
|
||||||
|
* session-routes.ts, and tmux-manager's spawn path), and the login-shell probe
|
||||||
|
* is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative
|
||||||
|
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
|
||||||
|
* to 5s on every request, forever.
|
||||||
|
*
|
||||||
|
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
|
||||||
|
* short-circuits — IO primitives that were not injected become inert stubs, so
|
||||||
|
* a suite can never scan the machine's PATH or spawn login shells (the same
|
||||||
|
* rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in
|
||||||
|
* `getClaudeCliVersion`). Tests opt back in through the injection hooks
|
||||||
|
* (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for
|
||||||
|
* fixtures that need the real filesystem predicate against their own temp
|
||||||
|
* files, via `allowRealIoUnderVitest`.
|
||||||
|
*
|
||||||
|
* @module utils/cli-executable-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { accessSync, constants, statSync } from 'node:fs';
|
||||||
|
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
|
||||||
|
|
||||||
|
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
||||||
|
const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||||
|
const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||||
|
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
|
||||||
|
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
|
||||||
|
|
||||||
|
/** First retry window after a full-chain resolution miss. */
|
||||||
|
const RESOLVE_RETRY_BASE_MS = 60_000;
|
||||||
|
/**
|
||||||
|
* Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on
|
||||||
|
* the claude version probe: that one is cosmetic, while this gates the Run
|
||||||
|
* flow, and "installing a CLI while the server is running is picked up without
|
||||||
|
* a restart" should stay true within minutes.
|
||||||
|
*/
|
||||||
|
const RESOLVE_RETRY_MAX_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait before re-running the resolution chain after `failures`
|
||||||
|
* consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors
|
||||||
|
* `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests.
|
||||||
|
*/
|
||||||
|
export function cliResolveRetryDelayMs(failures: number): number {
|
||||||
|
if (failures <= 0) return 0;
|
||||||
|
return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
|
||||||
|
|
||||||
|
export interface CliResolutionDiagnostics {
|
||||||
|
binary: string;
|
||||||
|
processPath: string;
|
||||||
|
shellPath: string;
|
||||||
|
shellArgs: string[];
|
||||||
|
searchDirs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliResolverHost {
|
||||||
|
processPath: string;
|
||||||
|
shellPath: string;
|
||||||
|
shellArgs: string[];
|
||||||
|
findOnProcessPath(binary: string): string | null;
|
||||||
|
findInLoginShell(binary: string): string | null;
|
||||||
|
exists(path: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateValidation<T> {
|
||||||
|
accepted: boolean;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliResolution<T = undefined> {
|
||||||
|
binaryPath: string;
|
||||||
|
directory: string;
|
||||||
|
source: CliResolutionSource;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliExecutableResolver<T = undefined> {
|
||||||
|
resolve(): CliResolution<T> | null;
|
||||||
|
diagnostics(): CliResolutionDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliResolverCommandOptions {
|
||||||
|
encoding: 'utf8';
|
||||||
|
timeout: number;
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'];
|
||||||
|
killSignal: 'SIGKILL';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
|
||||||
|
|
||||||
|
export interface ProductionCliResolverHostOptions {
|
||||||
|
processPath?: string;
|
||||||
|
shellPath?: string;
|
||||||
|
shellArgs?: string[];
|
||||||
|
runCommand?: CliResolverCommandRunner;
|
||||||
|
isExecutableFile?: (path: string) => boolean;
|
||||||
|
/**
|
||||||
|
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
|
||||||
|
* For tests that exercise `isExecutableRegularFile` against their own temp
|
||||||
|
* fixtures. Such a test must still inject `runCommand` if it can reach the
|
||||||
|
* login-shell step, or it would spawn a real interactive shell.
|
||||||
|
*/
|
||||||
|
allowRealIoUnderVitest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutableRegularFile(path: string): boolean {
|
||||||
|
try {
|
||||||
|
if (!statSync(path).isFile()) return false;
|
||||||
|
accessSync(path, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLoginShellResult(output: string, binary: string): string | null {
|
||||||
|
const lines = output.split(/\r?\n/).map((line) => line.trim());
|
||||||
|
const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER);
|
||||||
|
if (begin === -1) return null;
|
||||||
|
const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1);
|
||||||
|
if (end === -1) return null;
|
||||||
|
|
||||||
|
for (const candidate of lines.slice(begin + 1, end)) {
|
||||||
|
if (isAbsolute(candidate) && basename(candidate) === binary) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginShellCommand(binary: string): string {
|
||||||
|
return [
|
||||||
|
`printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`,
|
||||||
|
`command -v -- ${binary}`,
|
||||||
|
`printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`,
|
||||||
|
].join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost {
|
||||||
|
const shellPath = options.shellPath ?? resolveLocalShell();
|
||||||
|
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
|
||||||
|
const processPath = options.processPath ?? process.env.PATH ?? '';
|
||||||
|
// Hermeticity gate (see @fileoverview): under vitest, any IO primitive the
|
||||||
|
// caller did not inject is replaced by an inert stub. The suites must never
|
||||||
|
// depend on — or execute — whatever happens to be installed on the machine
|
||||||
|
// running them, and route tests hitting the per-CLI status endpoints would
|
||||||
|
// otherwise scan the real PATH and spawn real login shells on CI.
|
||||||
|
const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true;
|
||||||
|
const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile);
|
||||||
|
const runCommand: CliResolverCommandRunner =
|
||||||
|
options.runCommand ?? (inert ? () => '' : (file, args, commandOptions) => execFileSync(file, args, commandOptions));
|
||||||
|
const run = (file: string, args: string[]): string => {
|
||||||
|
try {
|
||||||
|
return runCommand(file, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill
|
||||||
|
// signal and then keeps waiting for the child to exit. Interactive bash
|
||||||
|
// ignores SIGTERM (the default), so a login shell stuck in a blocking
|
||||||
|
// .bash_profile would survive the timeout and block the server forever.
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
processPath,
|
||||||
|
shellPath,
|
||||||
|
shellArgs: [...shellArgs],
|
||||||
|
findOnProcessPath: (binary) => {
|
||||||
|
if (!SAFE_BINARY_NAME.test(binary)) return null;
|
||||||
|
for (const directory of processPath.split(delimiter).filter(Boolean)) {
|
||||||
|
const candidate = join(directory, binary);
|
||||||
|
if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
findInLoginShell: (binary) => {
|
||||||
|
if (!SAFE_BINARY_NAME.test(binary)) return null;
|
||||||
|
const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary);
|
||||||
|
return candidate && isExecutableFile(candidate) ? candidate : null;
|
||||||
|
},
|
||||||
|
exists: isExecutableFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCliExecutableResolver<T = undefined>(
|
||||||
|
options: {
|
||||||
|
binary: string;
|
||||||
|
searchDirs: string[];
|
||||||
|
validateCandidate?: (path: string) => CandidateValidation<T>;
|
||||||
|
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
|
||||||
|
now?: () => number;
|
||||||
|
},
|
||||||
|
host: CliResolverHost = createProductionCliResolverHost()
|
||||||
|
): CliExecutableResolver<T> {
|
||||||
|
if (!SAFE_BINARY_NAME.test(options.binary)) {
|
||||||
|
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
/** Successful resolution, cached for the process lifetime. */
|
||||||
|
let cached: CliResolution<T> | null = null;
|
||||||
|
/** Consecutive full-chain misses (drives the retry backoff). */
|
||||||
|
let failures = 0;
|
||||||
|
/** Timestamp of the most recent miss. */
|
||||||
|
let lastFailureAt = 0;
|
||||||
|
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||||
|
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||||
|
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||||
|
if (!validation.accepted) return null;
|
||||||
|
return {
|
||||||
|
binaryPath: path,
|
||||||
|
directory: dirname(path),
|
||||||
|
source,
|
||||||
|
metadata: validation.metadata,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolve() {
|
||||||
|
if (cached) return cached;
|
||||||
|
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||||
|
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||||
|
// backoff elapses. Without this, every status poll and Run click against
|
||||||
|
// a missing CLI froze the event loop for the full probe, forever.
|
||||||
|
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
|
||||||
|
|
||||||
|
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
|
||||||
|
if (!cached) {
|
||||||
|
for (const dir of options.searchDirs) {
|
||||||
|
cached = accept(join(dir, options.binary), 'common-directory');
|
||||||
|
if (cached) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cached) {
|
||||||
|
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
failures = 0;
|
||||||
|
lastFailureAt = 0;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
failures += 1;
|
||||||
|
lastFailureAt = now();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
diagnostics: () => ({
|
||||||
|
binary: options.binary,
|
||||||
|
processPath: host.processPath,
|
||||||
|
shellPath: host.shellPath,
|
||||||
|
shellArgs: [...host.shellArgs],
|
||||||
|
searchDirs: [...options.searchDirs],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDiagnosticField(value: string, emptyMarker: string): string {
|
||||||
|
const flattened = Array.from(value, (character) => {
|
||||||
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
|
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||||
|
return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!flattened) return emptyMarker;
|
||||||
|
if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened;
|
||||||
|
return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string {
|
||||||
|
const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)');
|
||||||
|
const shell = sanitizeDiagnosticField(
|
||||||
|
[diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '),
|
||||||
|
'(none)'
|
||||||
|
);
|
||||||
|
const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)');
|
||||||
|
return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`;
|
||||||
|
}
|
||||||
@@ -7,11 +7,9 @@
|
|||||||
* @module utils/codex-cli-resolver
|
* @module utils/codex-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||||
|
|
||||||
/** Common directories where the Codex CLI binary may be installed */
|
/** Common directories where the Codex CLI binary may be installed */
|
||||||
const CODEX_SEARCH_DIRS = [
|
const CODEX_SEARCH_DIRS = [
|
||||||
@@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'), // User bin
|
join(homedir(), 'bin'), // User bin
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the codex binary (empty string = searched but not found) */
|
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
|
||||||
let _codexDir: string | null = null;
|
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `codex` binary.
|
* Finds the directory containing the `codex` binary.
|
||||||
@@ -34,31 +32,7 @@ let _codexDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveCodexDir(): string | null {
|
export function resolveCodexDir(): string | null {
|
||||||
if (_codexDir !== null) return _codexDir || null;
|
return codexResolver.resolve()?.directory ?? null;
|
||||||
|
|
||||||
// Try `which` first (respects current PATH)
|
|
||||||
try {
|
|
||||||
const result = execSync('which codex', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
}).trim();
|
|
||||||
if (result && existsSync(result)) {
|
|
||||||
_codexDir = dirname(result);
|
|
||||||
return _codexDir;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Codex not in PATH, will check common locations
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of CODEX_SEARCH_DIRS) {
|
|
||||||
if (existsSync(join(dir, 'codex'))) {
|
|
||||||
_codexDir = dir;
|
|
||||||
return _codexDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_codexDir = ''; // mark as searched, not found
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null {
|
|||||||
export function isCodexAvailable(): boolean {
|
export function isCodexAvailable(): boolean {
|
||||||
return resolveCodexDir() !== null;
|
return resolveCodexDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCodexNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
* @module utils/gemini-cli-resolver
|
* @module utils/gemini-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||||
|
|
||||||
/** Common directories where the Gemini CLI binary may be installed */
|
/** Common directories where the Gemini CLI binary may be installed */
|
||||||
const GEMINI_SEARCH_DIRS = [
|
const GEMINI_SEARCH_DIRS = [
|
||||||
@@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'),
|
join(homedir(), 'bin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
|
||||||
let _geminiDir: string | null = null;
|
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `gemini` binary.
|
* Finds the directory containing the `gemini` binary.
|
||||||
@@ -33,30 +31,7 @@ let _geminiDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveGeminiDir(): string | null {
|
export function resolveGeminiDir(): string | null {
|
||||||
if (_geminiDir !== null) return _geminiDir || null;
|
return geminiResolver.resolve()?.directory ?? null;
|
||||||
|
|
||||||
try {
|
|
||||||
const result = execSync('which gemini', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
}).trim();
|
|
||||||
if (result && existsSync(result)) {
|
|
||||||
_geminiDir = dirname(result);
|
|
||||||
return _geminiDir;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Gemini not in PATH, will check common locations
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of GEMINI_SEARCH_DIRS) {
|
|
||||||
if (existsSync(join(dir, 'gemini'))) {
|
|
||||||
_geminiDir = dir;
|
|
||||||
return _geminiDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_geminiDir = '';
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null {
|
|||||||
export function isGeminiAvailable(): boolean {
|
export function isGeminiAvailable(): boolean {
|
||||||
return resolveGeminiDir() !== null;
|
return resolveGeminiDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGeminiNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
+16
-6
@@ -28,12 +28,22 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
|
|||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
export {
|
||||||
|
findClaudeDir,
|
||||||
|
getAugmentedPath,
|
||||||
|
getClaudeCliVersion,
|
||||||
|
getClaudeBinaryPath,
|
||||||
|
getClaudeNotFoundMessage,
|
||||||
|
} from './claude-cli-resolver.js';
|
||||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
|
||||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
|
||||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
export {
|
||||||
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
|
resolveAntigravityDir,
|
||||||
|
isAntigravityAvailable,
|
||||||
|
getAntigravityNotFoundMessage,
|
||||||
|
} from './antigravity-cli-resolver.js';
|
||||||
|
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||||
export type { FileQueryMatcher } from './file-query.js';
|
export type { FileQueryMatcher } from './file-query.js';
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
* @module utils/opencode-cli-resolver
|
* @module utils/opencode-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||||
|
|
||||||
/** Common directories where the OpenCode CLI binary may be installed */
|
/** Common directories where the OpenCode CLI binary may be installed */
|
||||||
const OPENCODE_SEARCH_DIRS = [
|
const OPENCODE_SEARCH_DIRS = [
|
||||||
@@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'), // User bin
|
join(homedir(), 'bin'), // User bin
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
|
||||||
let _openCodeDir: string | null = null;
|
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `opencode` binary.
|
* Finds the directory containing the `opencode` binary.
|
||||||
@@ -35,32 +33,7 @@ let _openCodeDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveOpenCodeDir(): string | null {
|
export function resolveOpenCodeDir(): string | null {
|
||||||
if (_openCodeDir !== null) return _openCodeDir || null;
|
return openCodeResolver.resolve()?.directory ?? null;
|
||||||
|
|
||||||
// Try `which` first (respects current PATH)
|
|
||||||
try {
|
|
||||||
const result = execSync('which opencode', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
}).trim();
|
|
||||||
if (result && existsSync(result)) {
|
|
||||||
_openCodeDir = dirname(result);
|
|
||||||
return _openCodeDir;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// OpenCode not in PATH, will check common locations
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check common installation directories
|
|
||||||
for (const dir of OPENCODE_SEARCH_DIRS) {
|
|
||||||
if (existsSync(join(dir, 'opencode'))) {
|
|
||||||
_openCodeDir = dir;
|
|
||||||
return _openCodeDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_openCodeDir = ''; // mark as searched, not found
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null {
|
|||||||
export function isOpenCodeAvailable(): boolean {
|
export function isOpenCodeAvailable(): boolean {
|
||||||
return resolveOpenCodeDir() !== null;
|
return resolveOpenCodeDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOpenCodeNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,11 +15,15 @@
|
|||||||
* @module utils/pi-cli-resolver
|
* @module utils/pi-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync, execSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
import { join } from 'node:path';
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import {
|
||||||
|
createCliExecutableResolver,
|
||||||
|
formatCliNotFoundMessage,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from './cli-executable-resolver.js';
|
||||||
|
|
||||||
/** Common directories where the Pi CLI binary may be installed */
|
/** Common directories where the Pi CLI binary may be installed */
|
||||||
const PI_SEARCH_DIRS = [
|
const PI_SEARCH_DIRS = [
|
||||||
@@ -45,10 +49,7 @@ const PI_SEARCH_DIRS = [
|
|||||||
*/
|
*/
|
||||||
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
||||||
|
|
||||||
/** Cached directory containing the pi binary (empty string = searched but not found) */
|
const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent';
|
||||||
let _piDir: string | null = null;
|
|
||||||
/** Cached version string reported by the resolved binary (empty string = probed, unusable) */
|
|
||||||
let _piVersion: string | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `pi --version` on a candidate path and return the trimmed version when it
|
* Run `pi --version` on a candidate path and return the trimmed version when it
|
||||||
@@ -57,7 +58,12 @@ let _piVersion: string | null = null;
|
|||||||
* (which is how an unrelated `pi` on PATH gets rejected).
|
* (which is how an unrelated `pi` on PATH gets rejected).
|
||||||
*
|
*
|
||||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||||
* whether the dev box happens to have pi installed.
|
* whether the dev box happens to have pi installed — and since `pi` is a short
|
||||||
|
* GENERIC name, this probe would EXECUTE whatever binary of that name the
|
||||||
|
* machine carries. The shared resolver host is already inert under vitest, so
|
||||||
|
* this gate is defense in depth for any opted-in host that still carries the
|
||||||
|
* default probe; tests drive resolution via `createPiResolverForTest`, whose
|
||||||
|
* injected probe bypasses it. Pinned by test/pi-cli-resolver.test.ts.
|
||||||
*/
|
*/
|
||||||
function probePiVersion(binPath: string): string | null {
|
function probePiVersion(binPath: string): string | null {
|
||||||
if (process.env.VITEST) return null;
|
if (process.env.VITEST) return null;
|
||||||
@@ -66,6 +72,9 @@ function probePiVersion(binPath: string): string | null {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// A stuck or hostile `pi` that ignores SIGTERM would survive the timeout
|
||||||
|
// and block the server (execFileSync keeps waiting after the signal).
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
}).trim();
|
}).trim();
|
||||||
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
||||||
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
|
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
|
||||||
@@ -77,6 +86,34 @@ function probePiVersion(binPath: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PiVersionProbe = (binPath: string) => string | null;
|
||||||
|
|
||||||
|
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion, now?: () => number) {
|
||||||
|
return createCliExecutableResolver<string>(
|
||||||
|
{
|
||||||
|
binary: 'pi',
|
||||||
|
searchDirs: PI_SEARCH_DIRS,
|
||||||
|
validateCandidate: (binPath) => {
|
||||||
|
const version = versionProbe(binPath);
|
||||||
|
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an isolated Pi wrapper around an injected host, version probe and
|
||||||
|
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||||
|
* is exactly what the hermeticity test exercises.
|
||||||
|
*/
|
||||||
|
export function createPiResolverForTest(host: CliResolverHost, versionProbe?: PiVersionProbe, now?: () => number) {
|
||||||
|
return createPiResolver(host, versionProbe ?? probePiVersion, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const piResolver = createPiResolver();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing a verified `pi` binary.
|
* Finds the directory containing a verified `pi` binary.
|
||||||
* Checks `which pi` first, then falls back to common install locations. Every
|
* Checks `which pi` first, then falls back to common install locations. Every
|
||||||
@@ -86,46 +123,7 @@ function probePiVersion(binPath: string): string | null {
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolvePiDir(): string | null {
|
export function resolvePiDir(): string | null {
|
||||||
if (_piDir !== null) return _piDir || null;
|
return piResolver.resolve()?.directory ?? null;
|
||||||
|
|
||||||
const accept = (binPath: string): string | null => {
|
|
||||||
// Under vitest the probe never runs, so existence alone decides (keeps the
|
|
||||||
// suites hermetic and matches how the sibling resolvers behave there).
|
|
||||||
if (process.env.VITEST) {
|
|
||||||
_piDir = dirname(binPath);
|
|
||||||
_piVersion = '';
|
|
||||||
return _piDir;
|
|
||||||
}
|
|
||||||
const version = probePiVersion(binPath);
|
|
||||||
if (!version) return null;
|
|
||||||
_piDir = dirname(binPath);
|
|
||||||
_piVersion = version;
|
|
||||||
return _piDir;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = execSync('which pi', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
}).trim();
|
|
||||||
if (result && existsSync(result)) {
|
|
||||||
const dir = accept(result);
|
|
||||||
if (dir) return dir;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// pi not in PATH, will check common locations
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of PI_SEARCH_DIRS) {
|
|
||||||
const binPath = join(dir, 'pi');
|
|
||||||
if (!existsSync(binPath)) continue;
|
|
||||||
const accepted = accept(binPath);
|
|
||||||
if (accepted) return accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
_piDir = '';
|
|
||||||
_piVersion = '';
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,12 +133,14 @@ export function isPiAvailable(): boolean {
|
|||||||
return resolvePiDir() !== null;
|
return resolvePiDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPiNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version reported by the resolved `pi` binary, or null when pi is unavailable
|
* Version reported by the resolved `pi` binary, or null when pi is unavailable.
|
||||||
* (or when the probe was skipped, i.e. under vitest). Surfaced through
|
* Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||||
* `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
|
||||||
*/
|
*/
|
||||||
export function getPiCliVersion(): string | null {
|
export function getPiCliVersion(): string | null {
|
||||||
resolvePiDir();
|
return piResolver.resolve()?.metadata ?? null;
|
||||||
return _piVersion || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-26
@@ -22,14 +22,14 @@
|
|||||||
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
|
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
|
||||||
* item stays pending, it just stops arming the tab alert on every client.
|
* item stays pending, it just stops arming the tab alert on every client.
|
||||||
*
|
*
|
||||||
* @dependencies utils (stripAnsi)
|
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||||
*
|
*
|
||||||
* @module web/approval-inbox
|
* @module web/approval-inbox
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stripAnsi } from '../utils/index.js';
|
import { stripAnsi, CLAUDE_WORKING_LINE_PATTERN } from '../utils/index.js';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,6 +106,12 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
|
|||||||
* single delayed re-capture picks up the frame the immediate capture missed.
|
* single delayed re-capture picks up the frame the immediate capture missed.
|
||||||
*/
|
*/
|
||||||
const RECAPTURE_DELAY_MS = 600;
|
const RECAPTURE_DELAY_MS = 600;
|
||||||
|
/**
|
||||||
|
* Delayed staleness pass for the late-hook case (see notePrompt). Comfortably
|
||||||
|
* clear of RECAPTURE_DELAY_MS so a dialog Ink has not painted yet is never
|
||||||
|
* mistaken for one that is gone.
|
||||||
|
*/
|
||||||
|
const STALE_CHECK_DELAY_MS = 3000;
|
||||||
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
||||||
const MAX_CONTEXT_CHARS = 4000;
|
const MAX_CONTEXT_CHARS = 4000;
|
||||||
const MAX_CONTEXT_LINES = 30;
|
const MAX_CONTEXT_LINES = 30;
|
||||||
@@ -195,7 +201,8 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
|
|||||||
export class ApprovalInbox {
|
export class ApprovalInbox {
|
||||||
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
||||||
private items = new Map<string, ApprovalItem>();
|
private items = new Map<string, ApprovalItem>();
|
||||||
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
/** Post-capture timers per item id (re-capture + the delayed staleness check). */
|
||||||
|
private itemTimers = new Map<string, ReturnType<typeof setTimeout>[]>();
|
||||||
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
||||||
private captures = new Map<string, () => string | null>();
|
private captures = new Map<string, () => string | null>();
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
@@ -229,31 +236,55 @@ export class ApprovalInbox {
|
|||||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||||
this.onPending?.(item);
|
this.onPending?.(item);
|
||||||
if (args.capture && !this.stopped) {
|
if (args.capture && !this.stopped) {
|
||||||
const timer = setTimeout(() => {
|
// Pass 1 (600ms): enrich the card with the painted frame.
|
||||||
this.recaptureTimers.delete(item.id);
|
this.scheduleForItem(item, RECAPTURE_DELAY_MS, () => {
|
||||||
// Only update the item if it is still the live one for the session.
|
|
||||||
if (this.items.get(args.sessionId)?.id !== item.id) return;
|
|
||||||
this.applyCapture(item, args.capture);
|
this.applyCapture(item, args.capture);
|
||||||
this.onUpdated?.(item);
|
this.onUpdated?.(item);
|
||||||
}, RECAPTURE_DELAY_MS);
|
});
|
||||||
this.recaptureTimers.set(item.id, timer);
|
// Pass 2: the late-hook staleness check. Claude Code fires the
|
||||||
|
// Notification behind the dialog, so a prompt answered before the hook
|
||||||
|
// lands creates an item for a dialog that is ALREADY gone: nothing ever
|
||||||
|
// parsed, so the "options vanished" test can never fire, `stop` may have
|
||||||
|
// gone by already, and the red alert then outlived reloads until the 12h
|
||||||
|
// TTL. This pass re-reads the pane and resolves when the frame proves no
|
||||||
|
// dialog is up. Deliberately LATER than the re-capture, whose whole
|
||||||
|
// reason for existing is that Ink may not have painted the dialog yet:
|
||||||
|
// resolving inside that window could clear the alert for a dialog that
|
||||||
|
// was about to appear.
|
||||||
|
this.scheduleForItem(item, STALE_CHECK_DELAY_MS, () => {
|
||||||
|
this.verifyStillAnswerable(item.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answer-time guard: re-capture the pane and check the dialog is still on
|
* Answer-time guard: re-capture the pane and check the dialog is still on
|
||||||
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
|
* screen before keystrokes are sent at it. If the dialog is gone (answered in
|
||||||
* frame parsed options: if a fresh capture then parses none, the dialog is
|
* the terminal moments ago) the item resolves and the answer is refused,
|
||||||
* gone (answered in the terminal moments ago), so the item resolves and the
|
* because the digit would land in whatever now has focus.
|
||||||
* answer must be refused, because the digit would land in whatever now has
|
*
|
||||||
* focus. Unparseable-from-the-start items stay answerable (approve/deny
|
* A fresh frame that parses NO options is conclusive in two cases, and only
|
||||||
* only), same risk the terminal user already carries.
|
* those; anything else stays answerable, so an unreadable capture keeps the
|
||||||
|
* alert rather than losing a live dialog:
|
||||||
|
*
|
||||||
|
* 1. The item HAD parsed options. They cannot vanish while the dialog is up.
|
||||||
|
* 2. The frame shows Claude actively running a turn. A modal dialog BLOCKS
|
||||||
|
* the turn, so a working line and a dialog cannot coexist — measured on
|
||||||
|
* v2.1.237: a live-dialog frame carries neither the `… (13s` timer nor
|
||||||
|
* even the `esc to interrupt` footer, which the dialog replaces with
|
||||||
|
* `Enter to select · ↑/↓ to navigate · Esc to cancel`.
|
||||||
|
*
|
||||||
|
* Case 2 is what closes the late-hook hole. Claude Code fires the
|
||||||
|
* Notification behind the dialog, so a prompt answered before the hook lands
|
||||||
|
* produces an item whose FIRST capture already has no dialog in it — never
|
||||||
|
* parsed, so case 1 can never fire, and the red alert then outlived even
|
||||||
|
* `stop` (which had already fired) and survived reloads until the 12h TTL.
|
||||||
*/
|
*/
|
||||||
verifyStillAnswerable(id: string): boolean {
|
verifyStillAnswerable(id: string): boolean {
|
||||||
const item = this.getById(id);
|
const item = this.getById(id);
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
if (item.kind === 'idle' || !item.options) return true;
|
if (item.kind === 'idle') return true;
|
||||||
const capture = this.captures.get(item.sessionId);
|
const capture = this.captures.get(item.sessionId);
|
||||||
if (!capture) return true;
|
if (!capture) return true;
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
@@ -266,14 +297,39 @@ export class ApprovalInbox {
|
|||||||
if (!context) return true;
|
if (!context) return true;
|
||||||
const options = parseDialogOptions(context);
|
const options = parseDialogOptions(context);
|
||||||
if (!options) {
|
if (!options) {
|
||||||
this.remove(item, 'resolved_in_terminal');
|
if (item.options || CLAUDE_WORKING_LINE_PATTERN.test(context)) {
|
||||||
return false;
|
this.remove(item, 'resolved_in_terminal');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // never parsed and the pane is not visibly working: unreadable, not gone
|
||||||
}
|
}
|
||||||
item.context = context;
|
item.context = context;
|
||||||
item.options = options;
|
item.options = options;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "This session's pane started moving again": re-verify its pending DIALOG
|
||||||
|
* item against the screen and resolve it if the dialog is gone.
|
||||||
|
*
|
||||||
|
* The staleness check itself lived only in `GET /api/approvals`, which
|
||||||
|
* nothing calls while a page is open (`seedApprovals()` runs on init and
|
||||||
|
* reconnect), so a dialog answered in the terminal kept its red tab alert for
|
||||||
|
* the whole rest of the turn. The `working` signal is exactly the moment an
|
||||||
|
* answer lands, and routing it through `verifyStillAnswerable` is what makes
|
||||||
|
* it safe to act on for a permission/question item: `working` is heuristic
|
||||||
|
* and can flap, but it only decides WHEN to look — the pane decides the
|
||||||
|
* outcome, and an unreadable capture keeps the alert.
|
||||||
|
*
|
||||||
|
* Cheap by construction: a Map miss unless a dialog item is actually pending,
|
||||||
|
* and the item is gone after the first successful resolve.
|
||||||
|
*/
|
||||||
|
resolveIfDialogGone(sessionId: string): void {
|
||||||
|
const item = this.getForSession(sessionId);
|
||||||
|
if (!item || item.kind === 'idle') return;
|
||||||
|
this.verifyStillAnswerable(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Pending item for a session, TTL-checked. */
|
/** Pending item for a session, TTL-checked. */
|
||||||
getForSession(sessionId: string): ApprovalItem | undefined {
|
getForSession(sessionId: string): ApprovalItem | undefined {
|
||||||
const item = this.items.get(sessionId);
|
const item = this.items.get(sessionId);
|
||||||
@@ -359,12 +415,27 @@ export class ApprovalInbox {
|
|||||||
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
|
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
for (const timer of this.recaptureTimers.values()) clearTimeout(timer);
|
for (const timers of this.itemTimers.values()) for (const timer of timers) clearTimeout(timer);
|
||||||
this.recaptureTimers.clear();
|
this.itemTimers.clear();
|
||||||
this.items.clear();
|
this.items.clear();
|
||||||
this.captures.clear();
|
this.captures.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` after `delayMs` if the item is still the live one for its session,
|
||||||
|
* tracking the timer so `remove()`/`stop()` can cancel it.
|
||||||
|
*/
|
||||||
|
private scheduleForItem(item: ApprovalItem, delayMs: number, fn: () => void): void {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const timers = this.itemTimers.get(item.id)?.filter((t) => t !== timer) ?? [];
|
||||||
|
if (timers.length > 0) this.itemTimers.set(item.id, timers);
|
||||||
|
else this.itemTimers.delete(item.id);
|
||||||
|
if (this.items.get(item.sessionId)?.id !== item.id) return;
|
||||||
|
fn();
|
||||||
|
}, delayMs);
|
||||||
|
this.itemTimers.set(item.id, [...(this.itemTimers.get(item.id) ?? []), timer]);
|
||||||
|
}
|
||||||
|
|
||||||
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
|
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
|
||||||
if (!capture) return;
|
if (!capture) return;
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
@@ -377,17 +448,30 @@ export class ApprovalInbox {
|
|||||||
if (!context) return;
|
if (!context) return;
|
||||||
item.context = context;
|
item.context = context;
|
||||||
// Idle prompts are not dialogs; never offer digit answers for them.
|
// Idle prompts are not dialogs; never offer digit answers for them.
|
||||||
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
|
if (item.kind === 'idle') return;
|
||||||
|
const options = parseDialogOptions(context);
|
||||||
|
// ⚠️ ADD-ONLY: a re-capture that parses NOTHING must never erase options a
|
||||||
|
// previous capture found. Claude Code delays the Notification hook behind
|
||||||
|
// the dialog (measured 6s here, up to ~30s), so the 600ms re-capture very
|
||||||
|
// often lands AFTER the user has already answered in the terminal, on a
|
||||||
|
// frame with no dialog in it. Clearing the field there was the whole bug:
|
||||||
|
// `verifyStillAnswerable` reads a MISSING `options` as "never parsed" and
|
||||||
|
// keeps such an item answerable by design, so a cleared field made the item
|
||||||
|
// permanently unsweepable — the red "needs you" alert then survived every
|
||||||
|
// `GET /api/approvals` and every page reload and only went away on `stop`
|
||||||
|
// (owner report 2026-08-20: a confirmed question left a tab flowing red for
|
||||||
|
// ~8 minutes while the turn ran on), and the stale card still accepted an
|
||||||
|
// answer, typing a bare `1` into a composer with no dialog under it.
|
||||||
|
// Keeping the parse means a later capture is CONCLUSIVE: options present +
|
||||||
|
// fresh frame without them == answered in the terminal.
|
||||||
|
if (options) item.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
|
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
|
||||||
this.items.delete(item.sessionId);
|
this.items.delete(item.sessionId);
|
||||||
this.captures.delete(item.sessionId);
|
this.captures.delete(item.sessionId);
|
||||||
const timer = this.recaptureTimers.get(item.id);
|
for (const timer of this.itemTimers.get(item.id) ?? []) clearTimeout(timer);
|
||||||
if (timer) {
|
this.itemTimers.delete(item.id);
|
||||||
clearTimeout(timer);
|
|
||||||
this.recaptureTimers.delete(item.id);
|
|
||||||
}
|
|
||||||
if (!this.stopped) {
|
if (!this.stopped) {
|
||||||
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
|
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-37
@@ -536,10 +536,10 @@ class CodemanApp {
|
|||||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||||
// Sessions whose full tmux scrollback has already been replayed this page load
|
// Non-shell sessions whose full tmux scrollback has already been replayed this
|
||||||
// (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the
|
// page load (COD-47). Shells deliberately start from a bounded tail because
|
||||||
// flag was consumed by whichever session auto-selected at page load, so every
|
// their scrollback can be very large; full history stays available on demand.
|
||||||
// OTHER tab started life with one visible frame of history (issue #205).
|
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
|
||||||
this._fullHistoryLoaded = new Set();
|
this._fullHistoryLoaded = new Set();
|
||||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||||
@@ -5268,6 +5268,22 @@ class CodemanApp {
|
|||||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_recordTerminalLoadTiming(timing) {
|
||||||
|
this._lastTerminalLoadTiming = timing;
|
||||||
|
console.info('[TERMINAL-PERF]', timing);
|
||||||
|
const resetAndParseMs =
|
||||||
|
(timing.cacheResetAndParseMs || 0) +
|
||||||
|
(timing.freshResetAndParseMs || 0) +
|
||||||
|
(timing.resetAndParseMs || 0);
|
||||||
|
const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0;
|
||||||
|
_crashDiag.log(
|
||||||
|
`TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` +
|
||||||
|
`ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` +
|
||||||
|
`reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
|
||||||
|
`server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
|
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
|
||||||
* while already at the top of what the browser has.
|
* while already at the top of what the browser has.
|
||||||
@@ -5296,6 +5312,11 @@ class CodemanApp {
|
|||||||
const sessionId = this.activeSessionId;
|
const sessionId = this.activeSessionId;
|
||||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||||
if (this.detachedSessions?.has(sessionId)) return;
|
if (this.detachedSessions?.has(sessionId)) return;
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
// A shell's full capture can be many megabytes. Replaying it from an
|
||||||
|
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||||
|
// behind the explicit "Load full history" button.
|
||||||
|
if (!force && session?.mode === 'shell') return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
// 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.
|
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||||
@@ -5307,13 +5328,32 @@ class CodemanApp {
|
|||||||
this._fullHistoryRepullAt.set(sessionId, now);
|
this._fullHistoryRepullAt.set(sessionId, now);
|
||||||
this._fullHistoryRepullInFlight = true;
|
this._fullHistoryRepullInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
const requestStartedAt = performance.now();
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||||
|
const headersReceivedAt = performance.now();
|
||||||
const payload = (await res.json())?.data ?? {};
|
const payload = (await res.json())?.data ?? {};
|
||||||
|
const bodyParsedAt = performance.now();
|
||||||
const buffer = payload.terminalBuffer;
|
const buffer = payload.terminalBuffer;
|
||||||
|
const timing = {
|
||||||
|
trigger: force ? 'full-history-button' : 'full-history-scroll',
|
||||||
|
mode: session?.mode || 'unknown',
|
||||||
|
full: true,
|
||||||
|
source: payload.source || 'unknown',
|
||||||
|
chars: buffer?.length || 0,
|
||||||
|
ttfbMs: headersReceivedAt - requestStartedAt,
|
||||||
|
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||||
|
resetAndParseMs: 0,
|
||||||
|
totalMs: 0,
|
||||||
|
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||||
|
refused: false,
|
||||||
|
};
|
||||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||||
// history into the terminal the user is now looking at.
|
// history into the terminal the user is now looking at.
|
||||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||||
|
timing.refused = true;
|
||||||
|
timing.totalMs = performance.now() - requestStartedAt;
|
||||||
|
this._recordTerminalLoadTiming(timing);
|
||||||
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||||
this._logScrollRouting?.('repull-refused-downgrade');
|
this._logScrollRouting?.('repull-refused-downgrade');
|
||||||
// The browser already holds more than tmux can give back, so there is
|
// The browser already holds more than tmux can give back, so there is
|
||||||
@@ -5324,17 +5364,32 @@ class CodemanApp {
|
|||||||
this._setHistoryTruncation(sessionId, payload);
|
this._setHistoryTruncation(sessionId, payload);
|
||||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||||
const rowsBefore = this.terminal.buffer.active.length;
|
const rowsBefore = this.terminal.buffer.active.length;
|
||||||
|
const replayStartedAt = performance.now();
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
const {
|
||||||
if (this.activeSessionId !== sessionId) return;
|
parsedAt,
|
||||||
this.terminalBufferCache.set(sessionId, buffer);
|
bufferLength: parsedBufferLength,
|
||||||
|
completed,
|
||||||
|
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||||
|
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||||
|
if (!completed || this.activeSessionId !== sessionId) return;
|
||||||
|
// Keep shell tab restores bounded too. A user-triggered full-history pull
|
||||||
|
// may be tens of MB; caching it would replay that whole payload again on
|
||||||
|
// the next tab switch before the normal 1MB tail fetch replaces it.
|
||||||
|
if (this.sessions.get(sessionId)?.mode !== 'shell') {
|
||||||
|
this.terminalBufferCache.set(sessionId, buffer);
|
||||||
|
} else {
|
||||||
|
this.terminalBufferCache.delete(sessionId);
|
||||||
|
}
|
||||||
// Hold the user's place. The replay is a superset that grew the buffer
|
// Hold the user's place. The replay is a superset that grew the buffer
|
||||||
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
|
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
|
||||||
// rows down; scrolling there reveals the recovered history above it instead
|
// rows down; scrolling there reveals the recovered history above it instead
|
||||||
// of teleporting them to the bottom the way a normal buffer load does.
|
// of teleporting them to the bottom the way a normal buffer load does.
|
||||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
const delta = parsedBufferLength - rowsBefore;
|
||||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||||
else this.terminal.scrollToTop();
|
else this.terminal.scrollToTop();
|
||||||
|
timing.totalMs = performance.now() - requestStartedAt;
|
||||||
|
this._recordTerminalLoadTiming(timing);
|
||||||
} catch {
|
} catch {
|
||||||
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
|
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
|
||||||
} finally {
|
} finally {
|
||||||
@@ -5614,6 +5669,7 @@ class CodemanApp {
|
|||||||
// COD-144: track whether the load painted nothing (empty fetch + no cache).
|
// COD-144: track whether the load painted nothing (empty fetch + no cache).
|
||||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||||
let bufferWasEmpty = false;
|
let bufferWasEmpty = false;
|
||||||
|
let cacheResetAndParseMs = 0;
|
||||||
try {
|
try {
|
||||||
// Fit terminal to container BEFORE writing any buffer data.
|
// Fit terminal to container BEFORE writing any buffer data.
|
||||||
// If the browser was resized while viewing another session, the terminal
|
// If the browser was resized while viewing another session, the terminal
|
||||||
@@ -5691,23 +5747,30 @@ class CodemanApp {
|
|||||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||||
// buffer once for a single clean transition.
|
// buffer once for a single clean transition.
|
||||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||||
let clearedForBusy = false;
|
let clearedBeforeFresh = false;
|
||||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
|
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
|
||||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
|
const cacheReplayStartedAt = performance.now();
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite(
|
||||||
|
cachedBuffer,
|
||||||
|
TERMINAL_CHUNK_SIZE,
|
||||||
|
bufferLoadOwner
|
||||||
|
);
|
||||||
|
cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt;
|
||||||
if (this._isStaleSelect(selectGen)) {
|
if (this._isStaleSelect(selectGen)) {
|
||||||
this._clearTerminalLoadState(sessionId, selectGen);
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
_crashDiag.log('CACHE_DONE');
|
_crashDiag.log('CACHE_DONE');
|
||||||
} else if (sessionIsBusy) {
|
} else if (sessionIsBusy || session?.mode === 'shell') {
|
||||||
// Clear stale content immediately — fresh buffer is being fetched
|
// Busy sessions have stale caches. Shell sessions deliberately skip even
|
||||||
|
// an idle cache so a changed 1MB tail cannot cause two back-to-back parses.
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
clearedForBusy = true;
|
clearedBeforeFresh = true;
|
||||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
_crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give TUI sessions a short chance to redraw after resize before the
|
// Give TUI sessions a short chance to redraw after resize before the
|
||||||
@@ -5725,26 +5788,29 @@ class CodemanApp {
|
|||||||
|
|
||||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||||
_crashDiag.log('FETCH_START');
|
_crashDiag.log('FETCH_START');
|
||||||
// The first load OF EACH SESSION this page load requests the full tmux
|
// TUI sessions still get one canonical full replay per page (COD-47/#205).
|
||||||
// scrollback (?full=1, COD-47) so history that scrolled off the server's byte
|
// A shell can retain hundreds of thousands of plain scrollback lines, so
|
||||||
// buffer comes back. Later switches to an already-replayed session keep the
|
// automatically replaying all of them makes tab selection scale with the
|
||||||
// fast ?tail= frame path, which is why this is a Set and not a flag: the flag
|
// entire session. Load its bounded 1MB tail first; the existing truncation
|
||||||
// version gave the full replay to the auto-selected tab and one frame of
|
// banner action fetches ?full=1 when the user explicitly asks for it.
|
||||||
// history to every other one (issue #205).
|
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||||
const useFullHistory = !this._fullHistoryLoaded.has(sessionId);
|
|
||||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||||
|
const fetchStartedAt = performance.now();
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
useFullHistory
|
useFullHistory
|
||||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||||
);
|
);
|
||||||
|
const headersReceivedAt = performance.now();
|
||||||
if (this._isStaleSelect(selectGen)) {
|
if (this._isStaleSelect(selectGen)) {
|
||||||
this._clearTerminalLoadState(sessionId, selectGen);
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await res.json())?.data ?? {};
|
const data = (await res.json())?.data ?? {};
|
||||||
|
const bodyParsedAt = performance.now();
|
||||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||||
|
|
||||||
|
let freshResetAndParseMs = 0;
|
||||||
if (data.terminalBuffer) {
|
if (data.terminalBuffer) {
|
||||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||||
@@ -5753,10 +5819,11 @@ class CodemanApp {
|
|||||||
// something other than the cache, so the fetched buffer must be
|
// something other than the cache, so the fetched buffer must be
|
||||||
// replayed even when it byte-matches the cache.
|
// replayed even when it byte-matches the cache.
|
||||||
const needsRewrite =
|
const needsRewrite =
|
||||||
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
|
restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
|
||||||
if (needsRewrite) {
|
if (needsRewrite) {
|
||||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
|
const replayStartedAt = performance.now();
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
// Truncation is reported OUT OF BAND (#258). This used to write a grey
|
// Truncation is reported OUT OF BAND (#258). This used to write a grey
|
||||||
// "... earlier output truncated ..." line into the
|
// "... earlier output truncated ..." line into the
|
||||||
@@ -5764,7 +5831,12 @@ class CodemanApp {
|
|||||||
// cannot be actioned, and is indistinguishable from real CLI output.
|
// cannot be actioned, and is indistinguishable from real CLI output.
|
||||||
this._setHistoryTruncation(sessionId, data);
|
this._setHistoryTruncation(sessionId, data);
|
||||||
// Use chunked write for large buffers to avoid UI jank
|
// Use chunked write for large buffers to avoid UI jank
|
||||||
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||||
|
data.terminalBuffer,
|
||||||
|
TERMINAL_CHUNK_SIZE,
|
||||||
|
bufferLoadOwner
|
||||||
|
);
|
||||||
|
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||||
if (this._isStaleSelect(selectGen)) {
|
if (this._isStaleSelect(selectGen)) {
|
||||||
this._clearTerminalLoadState(sessionId, selectGen);
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
return;
|
return;
|
||||||
@@ -5773,22 +5845,42 @@ class CodemanApp {
|
|||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache (cap at 20 entries)
|
// Shell selection always uses a fresh bounded tail, so retaining its
|
||||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
// payload only wastes memory and can evict useful TUI caches.
|
||||||
if (this.terminalBufferCache.size > 20) {
|
if (session?.mode === 'shell') {
|
||||||
// Evict oldest entry (first key in Map iteration order)
|
this.terminalBufferCache.delete(sessionId);
|
||||||
const oldest = this.terminalBufferCache.keys().next().value;
|
} else {
|
||||||
this.terminalBufferCache.delete(oldest);
|
// Update cache (cap at 20 entries)
|
||||||
|
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||||
|
if (this.terminalBufferCache.size > 20) {
|
||||||
|
// Evict oldest entry (first key in Map iteration order)
|
||||||
|
const oldest = this.terminalBufferCache.keys().next().value;
|
||||||
|
this.terminalBufferCache.delete(oldest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (!cachedBuffer) {
|
} else if (!cachedBuffer || clearedBeforeFresh) {
|
||||||
// No fresh buffer and no cache — clear any stale content
|
// Nothing was painted. If this path was not already cleared above,
|
||||||
this._resetTerminalForReplay();
|
// clear stale content now; either way queued live output must be flushed.
|
||||||
|
if (!clearedBeforeFresh) this._resetTerminalForReplay();
|
||||||
bufferWasEmpty = true;
|
bufferWasEmpty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const terminalLoadTiming = {
|
||||||
|
trigger: 'session-select',
|
||||||
|
mode: session?.mode || 'unknown',
|
||||||
|
full: useFullHistory,
|
||||||
|
source: data.source || 'unknown',
|
||||||
|
chars: data.terminalBuffer?.length || 0,
|
||||||
|
ttfbMs: headersReceivedAt - fetchStartedAt,
|
||||||
|
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||||
|
cacheResetAndParseMs,
|
||||||
|
freshResetAndParseMs,
|
||||||
|
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||||
|
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||||
|
};
|
||||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||||
// _finishBufferLoad internally (discarding queued events to prevent duplicate
|
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||||
// content); if we skipped the write (cache hit or empty), call it here.
|
// the write (cache hit or empty), call it here.
|
||||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||||
if (this._isLoadingBuffer) {
|
if (this._isLoadingBuffer) {
|
||||||
@@ -5917,9 +6009,12 @@ class CodemanApp {
|
|||||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
|
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
|
||||||
KeyboardHandler.onKeyboardShow();
|
KeyboardHandler.onKeyboardShow();
|
||||||
}
|
}
|
||||||
|
const selectDoneMs = performance.now() - _selStart;
|
||||||
|
terminalLoadTiming.selectDoneMs = selectDoneMs;
|
||||||
|
this._recordTerminalLoadTiming(terminalLoadTiming);
|
||||||
this._clearTerminalLoadState(sessionId, selectGen);
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
_crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
|
||||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||||
this._restoringFlushedState = false;
|
this._restoringFlushedState = false;
|
||||||
|
|||||||
+27
-25
@@ -27,8 +27,8 @@
|
|||||||
*
|
*
|
||||||
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||||
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||||
* Keyboard typing of single printable characters still goes through the
|
* Physical-keyboard commits are flushed immediately after the input event
|
||||||
* keydown handler (immediate, no debounce).
|
* exposes the final browser/IME text; keydown never guesses that text.
|
||||||
*
|
*
|
||||||
* ## Phantom character for Android backspace
|
* ## Phantom character for Android backspace
|
||||||
*
|
*
|
||||||
@@ -56,8 +56,7 @@ const CjkInput = (() => {
|
|||||||
let _compositionFlushTimer = null;
|
let _compositionFlushTimer = null;
|
||||||
let _dictationActive = false;
|
let _dictationActive = false;
|
||||||
let _dictationDecayTimer = null;
|
let _dictationDecayTimer = null;
|
||||||
let _keydownSentAt = 0;
|
let _printableKeydownAt = null;
|
||||||
let _keydownSentText = '';
|
|
||||||
const _listeners = {};
|
const _listeners = {};
|
||||||
|
|
||||||
const PHANTOM = '';
|
const PHANTOM = '';
|
||||||
@@ -197,6 +196,7 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
_send = send;
|
_send = send;
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_printableKeydownAt = null;
|
||||||
_flushTimer = null;
|
_flushTimer = null;
|
||||||
_textarea = document.getElementById('cjkInput');
|
_textarea = document.getElementById('cjkInput');
|
||||||
if (!_textarea) return this;
|
if (!_textarea) return this;
|
||||||
@@ -234,6 +234,7 @@ const CjkInput = (() => {
|
|||||||
};
|
};
|
||||||
_listeners.blur = () => {
|
_listeners.blur = () => {
|
||||||
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
||||||
|
_printableKeydownAt = null;
|
||||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||||
// UI may steal focus temporarily, and clearing the flag during that
|
// UI may steal focus temporarily, and clearing the flag during that
|
||||||
// window lets xterm's onData process duplicated input.
|
// window lets xterm's onData process duplicated input.
|
||||||
@@ -253,6 +254,7 @@ const CjkInput = (() => {
|
|||||||
_listeners.compositionstart = () => {
|
_listeners.compositionstart = () => {
|
||||||
_t(`compstart ${_vdesc(_textarea.value)}`);
|
_t(`compstart ${_vdesc(_textarea.value)}`);
|
||||||
_composing = true;
|
_composing = true;
|
||||||
|
_printableKeydownAt = null;
|
||||||
_cancelDebouncedFlush();
|
_cancelDebouncedFlush();
|
||||||
// Leave textarea.value untouched — programmatic changes during
|
// Leave textarea.value untouched — programmatic changes during
|
||||||
// compositionstart cancel the IME composition on iOS Safari.
|
// compositionstart cancel the IME composition on iOS Safari.
|
||||||
@@ -277,6 +279,7 @@ const CjkInput = (() => {
|
|||||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||||
_listeners.keydown = (e) => {
|
_listeners.keydown = (e) => {
|
||||||
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||||
|
_printableKeydownAt = null;
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_composing = false;
|
_composing = false;
|
||||||
@@ -325,16 +328,11 @@ const CjkInput = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single printable character: send immediately to PTY.
|
// A printable KeyboardEvent.key is the physical key, not necessarily
|
||||||
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
// the committed text. Let the browser/IME produce the input event so
|
||||||
// still enters the textarea and fires an input event — _keydownSentAt
|
// full-width punctuation and other layout transforms are preserved.
|
||||||
// tells the input handler to skip that echo.
|
|
||||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||||
e.preventDefault();
|
_printableKeydownAt = performance.now();
|
||||||
_send(e.key);
|
|
||||||
_keydownSentAt = performance.now();
|
|
||||||
_keydownSentText = e.key;
|
|
||||||
_resetToPhantom();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -343,6 +341,8 @@ const CjkInput = (() => {
|
|||||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||||
_listeners.input = (e) => {
|
_listeners.input = (e) => {
|
||||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
||||||
|
const printableKeydownAt = _printableKeydownAt;
|
||||||
|
_printableKeydownAt = null;
|
||||||
// ── Stuck-composition recovery ──
|
// ── Stuck-composition recovery ──
|
||||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||||
// matching compositionend. A stale _composing=true blocks every flush
|
// matching compositionend. A stale _composing=true blocks every flush
|
||||||
@@ -388,18 +388,18 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
if (_composing) return;
|
if (_composing) return;
|
||||||
|
|
||||||
// Keydown handler already sent this character — clear the textarea
|
// A recent physical printable key makes this insertText a keyboard
|
||||||
// echo that the IME inserted despite preventDefault. Content-checked:
|
// commit, so keep the old zero-latency path. Send the textarea's final
|
||||||
// only a value matching the sent char is an echo. Anything else (e.g.
|
// Unicode value, never KeyboardEvent.key, because the IME may have
|
||||||
// an IME committing CJK text right after a keydown-sent char) is real
|
// transformed punctuation or the active layout may differ.
|
||||||
// input and must flow through to the debounced flush, not be dropped.
|
if (
|
||||||
if (performance.now() - _keydownSentAt < 100) {
|
e.inputType === 'insertText' &&
|
||||||
const cur = _strip(_textarea.value);
|
printableKeydownAt !== null &&
|
||||||
if (cur === '' || cur === _keydownSentText) {
|
performance.now() - printableKeydownAt < 100
|
||||||
_t('echo-drop');
|
) {
|
||||||
_resetToPhantom();
|
_cancelDebouncedFlush();
|
||||||
return;
|
_flush();
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outside composition: keyboard typing or voice dictation.
|
// Outside composition: keyboard typing or voice dictation.
|
||||||
@@ -425,6 +425,7 @@ const CjkInput = (() => {
|
|||||||
clearTimeout(_compositionFlushTimer);
|
clearTimeout(_compositionFlushTimer);
|
||||||
_compositionFlushTimer = null;
|
_compositionFlushTimer = null;
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_printableKeydownAt = null;
|
||||||
_resetToPhantom();
|
_resetToPhantom();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -446,6 +447,7 @@ const CjkInput = (() => {
|
|||||||
}
|
}
|
||||||
window.cjkActive = false;
|
window.cjkActive = false;
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_printableKeydownAt = null;
|
||||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,8 +41,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_onHookElicitationComplete(data) {
|
_onHookElicitationComplete(data) {
|
||||||
// Question answered in the terminal: clear the action alert without
|
// Question answered in the terminal: clear the action alert without
|
||||||
// waiting for `stop` (the turn may keep running for a long time).
|
// waiting for `stop` (the turn may keep running for a long time).
|
||||||
|
// ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS,
|
||||||
|
// which resolves a session's pending item whatever its kind. An
|
||||||
|
// AskUserQuestion dialog arrives as `permission_prompt` (only MCP
|
||||||
|
// elicitation is `elicitation_dialog`), so clearing just the elicitation
|
||||||
|
// entry left the red alert armed on exactly the dialog these events are
|
||||||
|
// most often about. Normally the server's `approval:resolved` broadcast
|
||||||
|
// clears it too; this is the path that still works when the store holds no
|
||||||
|
// item for the session (restart, superseded).
|
||||||
if (data.sessionId) {
|
if (data.sessionId) {
|
||||||
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||||
|
this.clearPendingHooks(data.sessionId, 'permission_prompt');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+45
-111
@@ -287,11 +287,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
this._installTouchSelectionFocusGuard();
|
this._installTouchSelectionFocusGuard();
|
||||||
|
|
||||||
// Suppress xterm key handling during CJK IME composition.
|
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||||
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||||
// during composition, causing duplicate or garbled input.
|
// punctuation; returning false here would stop xterm before it can diff
|
||||||
|
// the helper textarea and emit the committed Unicode text.
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||||
|
|
||||||
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
|
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
|
||||||
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
|
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
|
||||||
@@ -399,72 +400,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Android virtual keyboard fix: catch non-composition input events.
|
|
||||||
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
|
||||||
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
|
||||||
// The custom key handler above returns false for keyCode 229, telling xterm
|
|
||||||
// to ignore the keydown. xterm.js expects the character to arrive via
|
|
||||||
// composition events, but since there's no composition, the character is lost.
|
|
||||||
// This listener catches those orphaned input events and forwards them to onData.
|
|
||||||
{
|
|
||||||
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
|
|
||||||
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
|
||||||
let composing = false;
|
|
||||||
let lastKeydownHandled = 0;
|
|
||||||
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
|
||||||
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
|
|
||||||
// Track when xterm handles a keydown normally (non-229 keyCode).
|
|
||||||
// If xterm processed the keydown, it will emit onData itself --
|
|
||||||
// the input event handler below must NOT re-send the character.
|
|
||||||
xtermTextarea.addEventListener('keydown', (e) => {
|
|
||||||
if (!e.isComposing && e.keyCode !== 229) {
|
|
||||||
lastKeydownHandled = Date.now();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
xtermTextarea.addEventListener('input', (e) => {
|
|
||||||
// Only handle insertText events outside of composition -- these are
|
|
||||||
// the ones xterm.js misses on Android virtual keyboards.
|
|
||||||
if (composing || e.isComposing) return;
|
|
||||||
if (e.inputType !== 'insertText' || !e.data) return;
|
|
||||||
// If xterm just handled a keydown (within 50ms), it already sent the
|
|
||||||
// char via onData. Skip to avoid double-send (e.g., Shift+A => AA).
|
|
||||||
if (Date.now() - lastKeydownHandled < 50) return;
|
|
||||||
// xterm.js may have already processed this via its own input handler.
|
|
||||||
// Check if the textarea was cleared by xterm (value is empty or just
|
|
||||||
// whitespace) -- if so, xterm handled it and we should not double-send.
|
|
||||||
// Use a microtask to check after xterm's own handlers have run.
|
|
||||||
const data = e.data;
|
|
||||||
const pendingBefore = this._localEchoOverlay?.pendingText || '';
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
if (
|
|
||||||
this._lastTerminalData?.data === data &&
|
|
||||||
performance.now() - this._lastTerminalData.time < 100
|
|
||||||
) {
|
|
||||||
xtermTextarea.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pendingAfter = this._localEchoOverlay?.pendingText || '';
|
|
||||||
if (
|
|
||||||
this._localEchoEnabled &&
|
|
||||||
pendingAfter.length > pendingBefore.length &&
|
|
||||||
pendingAfter.endsWith(data)
|
|
||||||
) {
|
|
||||||
xtermTextarea.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If xterm cleared the textarea, it processed the input -- skip.
|
|
||||||
const val = xtermTextarea.value;
|
|
||||||
if (!val || (val.trim() === '' && data !== ' ')) return;
|
|
||||||
// xterm didn't process it -- forward to terminal as if typed.
|
|
||||||
// Emit via onData path by writing to terminal's input handler.
|
|
||||||
this.terminal._core.coreService.triggerDataEvent(data, true);
|
|
||||||
// Clear the textarea to prevent xterm from processing it later.
|
|
||||||
xtermTextarea.value = '';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebGL renderer for GPU-accelerated terminal rendering.
|
// WebGL renderer for GPU-accelerated terminal rendering.
|
||||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||||
// but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents
|
// but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents
|
||||||
@@ -3046,12 +2981,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||||
* buffer while scrolling up is the user reaching for history the browser does
|
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
||||||
* not have, so pull the rest of tmux's scrollback (issue #205, see
|
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
|
||||||
* _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the
|
* automatic pulls because their captures can be large; their banner button is
|
||||||
* check is on the resulting position, and it is deliberately not folded into
|
* the explicit path. Must be called AFTER scrollLines(), since the check is on
|
||||||
* _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare
|
* the resulting position, and it is deliberately not folded into
|
||||||
* per scroll event, and the pull itself is cooldown-guarded.
|
* _noteTerminalUserScroll for exactly that reason.
|
||||||
*/
|
*/
|
||||||
_maybeLoadMoreHistoryOnScroll(lines) {
|
_maybeLoadMoreHistoryOnScroll(lines) {
|
||||||
if (lines >= 0) return;
|
if (lines >= 0) return;
|
||||||
@@ -3605,7 +3540,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = this.terminal.buffer.active;
|
const buffer = this.terminal.buffer.active;
|
||||||
const totalLines = buffer.baseY + buffer.length;
|
// `length` already includes scrollback + viewport rows. Adding baseY scans
|
||||||
|
// every scrollback row twice, starting with thousands of out-of-range calls.
|
||||||
|
const totalLines = buffer.length;
|
||||||
let lastNonEmptyLine = -1;
|
let lastNonEmptyLine = -1;
|
||||||
|
|
||||||
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
|
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
|
||||||
@@ -3635,8 +3572,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Uses _safeYield to spread work across frames; falls back to setTimeout
|
* Uses _safeYield to spread work across frames; falls back to setTimeout
|
||||||
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
|
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
|
||||||
* @param {string} buffer - The full terminal buffer to write
|
* @param {string} buffer - The full terminal buffer to write
|
||||||
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
* @param {number} chunkSize - Size of each chunk (default 32KB)
|
||||||
* @returns {Promise<void>} - Resolves when all chunks written
|
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
|
||||||
*/
|
*/
|
||||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
||||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||||
@@ -3645,9 +3582,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
|
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
const parseSnapshot = (completed = this._chunkedWriteGen === writeGen) => ({
|
||||||
|
parsedAt: performance.now(),
|
||||||
|
bufferLength: this.terminal?.buffer?.active?.length ?? 0,
|
||||||
|
completed,
|
||||||
|
});
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
this._finishBufferLoad(bufferLoadOwner);
|
this._finishBufferLoad(bufferLoadOwner);
|
||||||
resolve();
|
resolve(parseSnapshot());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3655,53 +3597,45 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// (from historical SSE data that was stored with markers)
|
// (from historical SSE data that was stored with markers)
|
||||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
// Only finish if we're still the active write — a newer write owns buffer load state
|
|
||||||
if (this._chunkedWriteGen === writeGen) {
|
|
||||||
this._finishBufferLoad(bufferLoadOwner);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
// For small buffers, write directly — single-frame render is fast enough
|
// For small buffers, write directly — single-frame render is fast enough
|
||||||
if (cleanBuffer.length <= chunkSize) {
|
if (cleanBuffer.length <= chunkSize) {
|
||||||
this.terminal.write(cleanBuffer, finish);
|
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
|
||||||
|
// The write is now ordered in xterm's queue. Release live output before
|
||||||
|
// parsing completes; subsequent writes stay behind it without being lost.
|
||||||
|
this._finishBufferLoad(bufferLoadOwner);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Large buffers: write in chunks across animation frames.
|
// Large buffers: enqueue paced chunks, then append an empty marker whose
|
||||||
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
|
// callback fires after xterm parses every preceding chunk. The live-output
|
||||||
// avoiding GPU stalls without needing to toggle the renderer.
|
// gate is released as soon as that marker is ordered, not after parsing, so
|
||||||
|
// new output queues behind history instead of being held or dropped.
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
const _chunkStart = performance.now();
|
const _chunkStart = performance.now();
|
||||||
let _chunkCount = 0;
|
let _chunkCount = 0;
|
||||||
const writeChunk = () => {
|
const writeChunk = () => {
|
||||||
// Abort if a newer chunked write started (user switched tabs)
|
// Abort if a newer chunked write started (user switched tabs)
|
||||||
if (this._chunkedWriteGen !== writeGen) {
|
if (this._chunkedWriteGen !== writeGen) {
|
||||||
resolve();
|
resolve(parseSnapshot(false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offset >= cleanBuffer.length) {
|
|
||||||
const _totalMs = performance.now() - _chunkStart;
|
|
||||||
console.log(
|
|
||||||
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
|
|
||||||
);
|
|
||||||
// Wait one more frame for xterm to finish rendering before resolving
|
|
||||||
this._safeYield(finish);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _ct0 = performance.now();
|
|
||||||
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
|
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
|
||||||
this.terminal.write(chunk);
|
offset += chunk.length;
|
||||||
const _cdt = performance.now() - _ct0;
|
|
||||||
_chunkCount++;
|
_chunkCount++;
|
||||||
if (_cdt > 50)
|
this.terminal.write(chunk);
|
||||||
console.warn(
|
if (offset >= cleanBuffer.length) {
|
||||||
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`
|
this.terminal.write('', () => {
|
||||||
);
|
const result = parseSnapshot();
|
||||||
offset += chunkSize;
|
const _totalMs = result.parsedAt - _chunkStart;
|
||||||
|
console.log(
|
||||||
|
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms parsed`
|
||||||
|
);
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
this._finishBufferLoad(bufferLoadOwner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule next chunk; rAF if possible, else setTimeout/Worker
|
// Schedule next chunk; rAF if possible, else setTimeout/Worker
|
||||||
// fallback so progress doesn't stall on occluded/unfocused windows.
|
// fallback so progress doesn't stall on occluded/unfocused windows.
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Repository-status check (App Settings → Updates → "Repository
|
||||||
|
* status").
|
||||||
|
*
|
||||||
|
* INFORMATIONAL companion to the release-tag self-updater (`self-update.ts`).
|
||||||
|
* Where the updater answers "is there a newer published release tag, and do you
|
||||||
|
* want to `git checkout` it", this module answers "where does my local checkout
|
||||||
|
* sit relative to the upstream project AND my own fork" — by commit ahead/behind
|
||||||
|
* count plus a short list of the incoming commits.
|
||||||
|
*
|
||||||
|
* This needs the actual commits locally, so it does a READ-ONLY `git fetch` of
|
||||||
|
* the compared ref per remote (updates only remote-tracking refs under `.git`,
|
||||||
|
* never the working tree), then `git rev-list`/`git log`. Works uniformly for
|
||||||
|
* GitHub and non-GitHub remotes (e.g. Bitbucket) — no release tags required.
|
||||||
|
*
|
||||||
|
* Remote selection (generalizable): by default the union of `origin` and the
|
||||||
|
* current branch's `@{upstream}` tracking remote, deduped. Override with the
|
||||||
|
* `CODEMAN_UPDATE_REMOTES` env var (comma-separated remote names) for any other
|
||||||
|
* layout (e.g. the common `origin`=fork / `upstream`=canonical convention).
|
||||||
|
*
|
||||||
|
* Split PURE helpers (parsing + remote-set/role/compare-ref decisions, unit
|
||||||
|
* tested) from the IO wrapper `getRepositoryStatus()` (touches git).
|
||||||
|
*
|
||||||
|
* PERFORMANCE: every git invocation is ASYNC (`execFile`), never `execFileSync`
|
||||||
|
* — the fetch path can spend `2 × FETCH_TIMEOUT_MS` per remote on the network,
|
||||||
|
* and a synchronous version froze the whole event loop (SSE, PTY streaming) for
|
||||||
|
* up to a minute per request. The computation is additionally single-flight
|
||||||
|
* with a short TTL cache: concurrent requests share one in-flight promise, and
|
||||||
|
* a fresh-enough result is served without spawning git at all.
|
||||||
|
*
|
||||||
|
* SECURITY: remote URLs (and git stderr echoing them) can embed
|
||||||
|
* `scheme://user:token@host` credentials, so every `url`/`error` field is
|
||||||
|
* passed through `redactGitCredentials()` (shared with `git-clone.ts`) before
|
||||||
|
* it reaches a client.
|
||||||
|
*
|
||||||
|
* @module web/repo-status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
|
||||||
|
import { getInstallInfo } from './self-update.js';
|
||||||
|
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { version: APP_VERSION } = require('../../package.json') as { version: string };
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** Network/git timeout for the fetch path (longer than EXEC_TIMEOUT_MS — hits network). */
|
||||||
|
const FETCH_TIMEOUT_MS = 15_000;
|
||||||
|
/** Max incoming commit subjects to list per remote. */
|
||||||
|
const MAX_INCOMING = 10;
|
||||||
|
/** Fresh-enough window for a cached status — repeated UI polls reuse it instead of re-running git. */
|
||||||
|
const STATUS_CACHE_TTL_MS = 45_000;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PURE helpers (unit tested, no IO)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git rev-list --left-right --count HEAD...<ref>` output.
|
||||||
|
* Git prints two tab-separated counts: LEFT (commits in HEAD not in ref → ahead)
|
||||||
|
* and RIGHT (commits in ref not in HEAD → behind). Returns null on malformed input.
|
||||||
|
*/
|
||||||
|
export function parseAheadBehind(out: string): { ahead: number; behind: number } | null {
|
||||||
|
const m = out.trim().match(/^(\d+)\s+(\d+)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git log --oneline` output into commits. Each non-empty line is
|
||||||
|
* `<sha> <subject>`; the first whitespace-delimited token is the SHA.
|
||||||
|
*/
|
||||||
|
export function parseLogLines(out: string): RepoIncomingCommit[] {
|
||||||
|
const commits: RepoIncomingCommit[] = [];
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const idx = trimmed.indexOf(' ');
|
||||||
|
if (idx === -1) {
|
||||||
|
commits.push({ sha: trimmed, subject: '' });
|
||||||
|
} else {
|
||||||
|
commits.push({ sha: trimmed.slice(0, idx), subject: trimmed.slice(idx + 1).trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the default branch name from `git ls-remote --symref <remote> HEAD`
|
||||||
|
* output (a line like `ref: refs/heads/master\tHEAD`). Returns null if absent.
|
||||||
|
*
|
||||||
|
* SECURITY: this parses UNTRUSTED remote output, and the result flows into a
|
||||||
|
* later `git fetch <remote> <branch>` positional. The capture is constrained to
|
||||||
|
* start with an alphanumeric (no leading `-`) so a hostile remote can't return
|
||||||
|
* `ref: refs/heads/--upload-pack=<cmd>\tHEAD` and smuggle an argv flag (RCE) —
|
||||||
|
* see `isSafeGitPositional` for the defense-in-depth re-check at the call site.
|
||||||
|
*/
|
||||||
|
export function parseSymrefDefaultBranch(out: string): string | null {
|
||||||
|
const m = out.match(/^ref:\s+refs\/heads\/([A-Za-z0-9_./][A-Za-z0-9_./+-]*)\s+HEAD$/m);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a value that would be unsafe as a git positional argument (remote name
|
||||||
|
* or branch). A leading `-` lets untrusted ls-remote/symref output or a stray
|
||||||
|
* `CODEMAN_UPDATE_REMOTES` entry inject an option (e.g. `--upload-pack=<cmd>`)
|
||||||
|
* into a subsequent `git fetch`. Empty values are rejected too.
|
||||||
|
*/
|
||||||
|
export function isSafeGitPositional(value: string | null | undefined): value is string {
|
||||||
|
return typeof value === 'string' && value.length > 0 && !value.startsWith('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a comma-separated env value into trimmed, non-empty names. */
|
||||||
|
export function parseRemotesEnv(value: string | null | undefined): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide which remote NAMES the status view covers, in display order.
|
||||||
|
*
|
||||||
|
* - If `envRemotes` is non-empty, use exactly those that actually exist (order
|
||||||
|
* preserved). This is the full-control escape hatch.
|
||||||
|
* - Otherwise the union of `origin` (if it exists) and the tracking remote (if
|
||||||
|
* any), deduped. The tracking remote is listed first when it isn't `origin`,
|
||||||
|
* so "your fork" leads and "upstream" follows.
|
||||||
|
*/
|
||||||
|
export function resolveRemoteSet(opts: {
|
||||||
|
existingRemotes: string[];
|
||||||
|
trackingRemote: string | null;
|
||||||
|
envRemotes: string[];
|
||||||
|
}): string[] {
|
||||||
|
const exists = new Set(opts.existingRemotes);
|
||||||
|
if (opts.envRemotes.length > 0) {
|
||||||
|
return dedupe(opts.envRemotes.filter((n) => exists.has(n)));
|
||||||
|
}
|
||||||
|
const out: string[] = [];
|
||||||
|
if (opts.trackingRemote && exists.has(opts.trackingRemote) && opts.trackingRemote !== 'origin') {
|
||||||
|
out.push(opts.trackingRemote);
|
||||||
|
}
|
||||||
|
if (exists.has('origin')) out.push('origin');
|
||||||
|
if (opts.trackingRemote && exists.has(opts.trackingRemote)) out.push(opts.trackingRemote);
|
||||||
|
return dedupe(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a remote's role relative to the tracking remote. */
|
||||||
|
export function roleForRemote(name: string, trackingRemote: string | null): RepoRemoteRole {
|
||||||
|
if (trackingRemote && name === trackingRemote) return 'tracking';
|
||||||
|
if (name === 'origin' || name === 'upstream') return 'upstream';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupe(names: string[]): string[] {
|
||||||
|
return [...new Set(names)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the remote name from an `@{upstream}` short ref like `origin/master`.
|
||||||
|
*
|
||||||
|
* A ref with NO slash is a LOCAL-branch upstream (`git branch -u otherbranch`):
|
||||||
|
* `rev-parse --abbrev-ref @{upstream}` prints just the branch name, and there
|
||||||
|
* is no remote in it — the old `slice(0, indexOf('/'))` became `slice(0, -1)`
|
||||||
|
* there and yielded garbage like `maste`. Returns null for that case (treated
|
||||||
|
* as "no tracking remote"), and for empty/degenerate refs.
|
||||||
|
*/
|
||||||
|
export function parseTrackingRemote(trackingRef: string | null | undefined): string | null {
|
||||||
|
if (!trackingRef) return null;
|
||||||
|
const idx = trackingRef.indexOf('/');
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
return trackingRef.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact embedded `scheme://user:secret@host` credentials from the fields of
|
||||||
|
* one remote's status that can carry them: the remote URL itself, and the
|
||||||
|
* error string (which may include git stderr echoing that URL back).
|
||||||
|
*/
|
||||||
|
export function redactRemoteStatus(status: RepoRemoteStatus): RepoRemoteStatus {
|
||||||
|
const out: RepoRemoteStatus = { ...status, url: redactGitCredentials(status.url) };
|
||||||
|
if (out.error !== undefined) out.error = redactGitCredentials(out.error);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-flight TTL cache around an async compute: concurrent callers await
|
||||||
|
* the SAME in-flight promise, and a result younger than `ttlMs` is returned
|
||||||
|
* without recomputing. A rejected compute is never cached, so the next call
|
||||||
|
* retries. `now` is injectable for deterministic TTL tests.
|
||||||
|
*/
|
||||||
|
export function createSingleFlightCache<T>(
|
||||||
|
ttlMs: number,
|
||||||
|
compute: () => Promise<T>
|
||||||
|
): { get(now?: number): Promise<T> } {
|
||||||
|
let cachedAt = 0;
|
||||||
|
let cachedValue: T | undefined;
|
||||||
|
let hasValue = false;
|
||||||
|
let inFlight: Promise<T> | null = null;
|
||||||
|
return {
|
||||||
|
get(now = Date.now()): Promise<T> {
|
||||||
|
if (hasValue && now - cachedAt < ttlMs) return Promise.resolve(cachedValue as T);
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = compute().then(
|
||||||
|
(value) => {
|
||||||
|
cachedValue = value;
|
||||||
|
hasValue = true;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
inFlight = null;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
inFlight = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return inFlight;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IO wrapper
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GitResult {
|
||||||
|
ok: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run git non-interactively (no credential or SSH prompts — a missing key/cred
|
||||||
|
* fails fast instead of hanging; env via the shared `gitNonInteractiveEnv()`,
|
||||||
|
* which also closes the askpass/GCM/DISPLAY prompt paths). ASYNC on purpose:
|
||||||
|
* the fetch path hits the network for up to `FETCH_TIMEOUT_MS`, and a sync
|
||||||
|
* spawn would block the event loop for the whole wait.
|
||||||
|
*/
|
||||||
|
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout,
|
||||||
|
env: gitNonInteractiveEnv(),
|
||||||
|
});
|
||||||
|
return { ok: true, stdout: stdout.trim(), stderr: '' };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stdout: e.stdout ? String(e.stdout).trim() : '',
|
||||||
|
stderr: e.stderr ? String(e.stderr).trim() : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First line of stderr, trimmed — a compact human-readable failure reason. */
|
||||||
|
function firstLine(s: string): string {
|
||||||
|
return (
|
||||||
|
s
|
||||||
|
.split('\n')
|
||||||
|
.find((l) => l.trim())
|
||||||
|
?.trim() ?? 'git command failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute ahead/behind + incoming commits for one already-selected remote. */
|
||||||
|
async function statusForRemote(
|
||||||
|
dir: string,
|
||||||
|
name: string,
|
||||||
|
trackingRemote: string,
|
||||||
|
trackingRef: string | null
|
||||||
|
): Promise<RepoRemoteStatus> {
|
||||||
|
const role = roleForRemote(name, trackingRemote);
|
||||||
|
const url = (await runGit(['remote', 'get-url', name], dir)).stdout || '';
|
||||||
|
const base: RepoRemoteStatus = { name, url, role, compareRef: '', ahead: 0, behind: 0, incoming: [] };
|
||||||
|
|
||||||
|
// SECURITY: the remote name reaches git as a positional; reject a `-` prefix
|
||||||
|
// (e.g. a stray CODEMAN_UPDATE_REMOTES entry) before it can act as a flag.
|
||||||
|
if (!isSafeGitPositional(name)) {
|
||||||
|
return { ...base, error: `Refusing unsafe remote name "${name}".` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the compare ref + the remote branch to fetch.
|
||||||
|
let branch: string | null;
|
||||||
|
if (role === 'tracking' && trackingRef) {
|
||||||
|
// e.g. trackingRef = "bitbucket/local" → branch = "local"
|
||||||
|
branch = trackingRef.slice(name.length + 1) || null;
|
||||||
|
} else {
|
||||||
|
const symref = await runGit(['ls-remote', '--symref', name, 'HEAD'], dir, FETCH_TIMEOUT_MS);
|
||||||
|
branch = symref.ok ? parseSymrefDefaultBranch(symref.stdout) : null;
|
||||||
|
if (!branch && !symref.ok) {
|
||||||
|
return { ...base, error: `Could not reach ${name}: ${firstLine(symref.stderr)}` };
|
||||||
|
}
|
||||||
|
branch = branch ?? 'master';
|
||||||
|
}
|
||||||
|
if (!branch) return { ...base, error: `Could not resolve a branch on ${name}.` };
|
||||||
|
// SECURITY: defense-in-depth — `branch` may come from untrusted symref output
|
||||||
|
// or a tracking-ref slice; never let a `-`-prefixed value reach `git fetch`.
|
||||||
|
if (!isSafeGitPositional(branch)) {
|
||||||
|
return { ...base, error: `Refusing unsafe branch name "${branch}" from ${name}.` };
|
||||||
|
}
|
||||||
|
const compareRef = `${name}/${branch}`;
|
||||||
|
|
||||||
|
// Read-only fetch of just that ref so the local rev-list/log can see it.
|
||||||
|
// `--` ends option parsing so neither `name` nor `branch` can be read as a flag.
|
||||||
|
const fetched = await runGit(['fetch', '--no-tags', name, '--', branch], dir, FETCH_TIMEOUT_MS);
|
||||||
|
if (!fetched.ok) {
|
||||||
|
return { ...base, compareRef, error: `Could not fetch ${compareRef}: ${firstLine(fetched.stderr)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = await runGit(['rev-list', '--left-right', '--count', `HEAD...${compareRef}`], dir);
|
||||||
|
if (!counts.ok) {
|
||||||
|
return { ...base, compareRef, error: `Could not compare against ${compareRef}: ${firstLine(counts.stderr)}` };
|
||||||
|
}
|
||||||
|
const ab = parseAheadBehind(counts.stdout);
|
||||||
|
if (!ab) return { ...base, compareRef, error: `Unexpected git output comparing ${compareRef}.` };
|
||||||
|
|
||||||
|
const log = await runGit(['log', '--oneline', '-n', String(MAX_INCOMING), `HEAD..${compareRef}`], dir);
|
||||||
|
const incoming = log.ok ? parseLogLines(log.stdout) : [];
|
||||||
|
|
||||||
|
return { ...base, compareRef, ahead: ab.ahead, behind: ab.behind, incoming };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The uncached computation behind `getRepositoryStatus()`. */
|
||||||
|
async function computeRepositoryStatus(): Promise<RepositoryStatusResult> {
|
||||||
|
const checkedAt = Date.now();
|
||||||
|
const info = getInstallInfo();
|
||||||
|
const base: RepositoryStatusResult = {
|
||||||
|
checkedAt,
|
||||||
|
isGit: info.installKind === 'git',
|
||||||
|
currentVersion: info.currentVersion || APP_VERSION,
|
||||||
|
remotes: [],
|
||||||
|
};
|
||||||
|
if (info.installKind !== 'git') {
|
||||||
|
return { ...base, error: 'Not a git install — repository status is unavailable.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = info.installDir;
|
||||||
|
|
||||||
|
// Tracking ref of the current branch, e.g. "bitbucket/local" (empty if none).
|
||||||
|
const trackingRef =
|
||||||
|
(await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], dir)).stdout || null;
|
||||||
|
const trackingRemote = parseTrackingRemote(trackingRef);
|
||||||
|
// A local-branch upstream (no slash) carries no remote — drop the ref too, so
|
||||||
|
// nothing downstream can mistake a bare branch name for a remote-tracking ref.
|
||||||
|
const remoteTrackingRef = trackingRemote ? trackingRef : null;
|
||||||
|
|
||||||
|
const remotesOut = await runGit(['remote'], dir);
|
||||||
|
const existingRemotes = remotesOut.ok
|
||||||
|
? remotesOut.stdout
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const selected = resolveRemoteSet({
|
||||||
|
existingRemotes,
|
||||||
|
trackingRemote,
|
||||||
|
envRemotes: parseRemotesEnv(process.env.CODEMAN_UPDATE_REMOTES),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return { ...base, error: 'No comparable remotes found (set CODEMAN_UPDATE_REMOTES to choose).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequential on purpose: concurrent `git fetch` in one repo can contend on
|
||||||
|
// ref locks, and the event loop no longer cares how long this takes.
|
||||||
|
const remotes: RepoRemoteStatus[] = [];
|
||||||
|
for (const name of selected) {
|
||||||
|
// SECURITY: redact `scheme://user:token@host` credentials from the URL and
|
||||||
|
// any error string (git stderr echoes the URL back) before they leave the server.
|
||||||
|
remotes.push(redactRemoteStatus(await statusForRemote(dir, name, trackingRemote ?? '', remoteTrackingRef)));
|
||||||
|
}
|
||||||
|
return { ...base, remotes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCache = createSingleFlightCache(STATUS_CACHE_TTL_MS, computeRepositoryStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect how the local checkout sits relative to the configured remotes.
|
||||||
|
* Each remote is fetched + compared independently; a single unreachable remote
|
||||||
|
* surfaces as that card's `error` and never fails the whole call.
|
||||||
|
*
|
||||||
|
* Single-flight + TTL-cached: concurrent requests share one in-flight
|
||||||
|
* computation, and a result younger than `STATUS_CACHE_TTL_MS` is served
|
||||||
|
* without spawning git.
|
||||||
|
*/
|
||||||
|
export function getRepositoryStatus(): Promise<RepositoryStatusResult> {
|
||||||
|
return statusCache.get();
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
|
||||||
|
|
||||||
|
export interface ResponseViewerTranscriptBlock {
|
||||||
|
kind: ResponseViewerTranscriptKind;
|
||||||
|
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
|
||||||
|
/** What the frontend renders by: 'user' gets the "You" badge, everything else the agent badge. */
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
||||||
|
// would drag node-pty and the whole session layer into this pure module, so the
|
||||||
|
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
||||||
|
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi']);
|
||||||
|
|
||||||
|
function isPromptLine(line: string): boolean {
|
||||||
|
return /^\s*›\s*/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDividerDashChar(ch: string): boolean {
|
||||||
|
return ch === '─' || ch === '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitespaceChar(ch: string): boolean {
|
||||||
|
return /\s/.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear-time equivalent of the old /^[─-]+\s*(.+?)\s*[─-]{3,}$/. The lazy
|
||||||
|
// middle of that pattern backtracked catastrophically on a long dash run that
|
||||||
|
// does NOT end in 3+ dashes (measured >2min at 8,000 chars) — and pane text is
|
||||||
|
// agent-controlled with buffers up to 32MB, so this ran on hostile input. Same
|
||||||
|
// accept set and same captured content, computed with counters; equivalence is
|
||||||
|
// pinned char-for-char against the old regex by the brute-force corpus test in
|
||||||
|
// test/response-viewer-transcript.test.ts.
|
||||||
|
function normalizeDividerStatusLine(line: string): string | null {
|
||||||
|
const s = line.trim();
|
||||||
|
const n = s.length;
|
||||||
|
// Minimum match: 1 leading dash + 1 content char + 3 trailing dashes.
|
||||||
|
if (n < 5) return null;
|
||||||
|
|
||||||
|
let lead = 0;
|
||||||
|
while (lead < n && isDividerDashChar(s.charAt(lead))) lead += 1;
|
||||||
|
if (lead === 0) return null;
|
||||||
|
|
||||||
|
let trail = 0;
|
||||||
|
while (trail < n && isDividerDashChar(s.charAt(n - 1 - trail))) trail += 1;
|
||||||
|
if (trail < 3) return null;
|
||||||
|
|
||||||
|
// The regex was greedy on the leading run but gave dashes back until at least
|
||||||
|
// one content char plus the 3-dash tail fit (an all-dash line matched with a
|
||||||
|
// single leftover dash as its "content"), so the content window starts at the
|
||||||
|
// end of the leading run, clamped to leave 4 chars.
|
||||||
|
const contentStart = Math.min(lead, n - 4);
|
||||||
|
let ws = 0;
|
||||||
|
while (contentStart + ws < n - 4 && isWhitespaceChar(s.charAt(contentStart + ws))) ws += 1;
|
||||||
|
const from = contentStart + ws;
|
||||||
|
|
||||||
|
// The lazy middle stopped at the first position from which "optional
|
||||||
|
// whitespace, then dashes to end-of-line" matches: the start of the
|
||||||
|
// whitespace padding in front of the trailing dash run (never before the
|
||||||
|
// first content char).
|
||||||
|
const tailStart = n - trail;
|
||||||
|
let padded = tailStart;
|
||||||
|
while (padded > 0 && isWhitespaceChar(s.charAt(padded - 1))) padded -= 1;
|
||||||
|
const end = Math.max(from + 1, padded);
|
||||||
|
|
||||||
|
return s.slice(from, end).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDividerOnlyLine(line: string): boolean {
|
||||||
|
return /^[\s─-]{8,}$/.test(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// COD-227: unambiguous Codex tool-call markers. These only ever appear as internal
|
||||||
|
// activity, never as ordinary assistant prose, so they are always a Tool block.
|
||||||
|
function isToolActivityMarker(line: string): boolean {
|
||||||
|
return /^\s*[•*-]\s+(Calling|Called)\b/.test(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// COD-227: action verbs that ALSO occur in ordinary assistant prose (e.g.
|
||||||
|
// "• Created COD-226: …"). These are a Tool header only when corroborated by a
|
||||||
|
// box-drawing result tree on the next non-blank line (see the caller); the verb
|
||||||
|
// alone is not sufficient.
|
||||||
|
function isToolVerbBullet(line: string): boolean {
|
||||||
|
return /^\s*[•*-]\s+(Explored|Viewed|Read|Edited|Updated|Created|Deleted|Ran|Searched|Opened|Listed|Found|Applied|Patched|Used|Wrote|Executed|Modified|Analyzed|Compared|Fetched|Installed)\b/.test(
|
||||||
|
line.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuine Codex tool block renders its result as a box-drawing tree (└ │ ├).
|
||||||
|
function isBoxDrawingLine(line: string): boolean {
|
||||||
|
return /^[│├└]/.test(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look past blank lines from `fromIndex + 1` for the next non-blank line and report
|
||||||
|
// whether it is a box-drawing tool-result line — the signal that a verb bullet is a
|
||||||
|
// real tool block rather than assistant prose that happens to start with a verb.
|
||||||
|
function nextNonBlankIsBoxDrawing(lines: string[], fromIndex: number): boolean {
|
||||||
|
for (let j = fromIndex + 1; j < lines.length; j += 1) {
|
||||||
|
const trimmed = (lines[j] || '').trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
return isBoxDrawingLine(trimmed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolContinuationLine(line: string, currentKind: ResponseViewerTranscriptKind | null): boolean {
|
||||||
|
if (currentKind !== 'tool') return false;
|
||||||
|
const trimmed = line.trimEnd();
|
||||||
|
if (!trimmed) return true;
|
||||||
|
return /^\s*[│├└]/.test(trimmed) || /^\s{2,}\S/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStatusLine(line: string, mode: string): boolean {
|
||||||
|
if (!EXTERNAL_CLI_MODES.has(mode)) return false;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (normalizeDividerStatusLine(trimmed)) return true;
|
||||||
|
if (/^(model|directory):\s+/i.test(trimmed)) return true;
|
||||||
|
if (/\bContext\b.*\bleft\b/i.test(trimmed)) return true;
|
||||||
|
if (/\b\/model to change\b/i.test(trimmed)) return true;
|
||||||
|
if (/\bReady\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||||
|
if (/^(gpt|o\d|claude|gemini)\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||||
|
if (/^Tip:/i.test(trimmed)) return true;
|
||||||
|
if (/^\s*[•*-]\s+(Working|Thinking|Loading|Starting\b|Waiting\b)/i.test(trimmed)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandaloneMarkdownLine(line: string): boolean {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (/^#{1,6}\s/.test(trimmed)) return true;
|
||||||
|
if (/^>\s/.test(trimmed)) return true;
|
||||||
|
if (/^(```|~~~)/.test(trimmed)) return true;
|
||||||
|
if (/^[-*+]\s/.test(trimmed)) return true;
|
||||||
|
if (/^\d+[.)]\s/.test(trimmed)) return true;
|
||||||
|
if (/^\|/.test(trimmed)) return true;
|
||||||
|
if (/^\s{4,}\S/.test(line)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldJoinWrappedLine(previous: string, next: string): boolean {
|
||||||
|
const prev = previous.trimEnd();
|
||||||
|
const curr = next.trim();
|
||||||
|
if (!prev || !curr) return false;
|
||||||
|
if (/[.!?]$/.test(prev)) return false;
|
||||||
|
if (/[:;]$/.test(prev)) return false;
|
||||||
|
if (isStandaloneMarkdownLine(curr)) return false;
|
||||||
|
if (/^[a-z(]/.test(curr)) return true;
|
||||||
|
if (prev.length >= 72 && /^[A-Za-z0-9"'(]/.test(curr)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWrappedText(lines: string[]): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
let paragraph = '';
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (!paragraph) return;
|
||||||
|
out.push(paragraph);
|
||||||
|
paragraph = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
flushParagraph();
|
||||||
|
if (out[out.length - 1] !== '') out.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isStandaloneMarkdownLine(line)) {
|
||||||
|
flushParagraph();
|
||||||
|
out.push(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!paragraph) {
|
||||||
|
paragraph = trimmed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (shouldJoinWrappedLine(paragraph, trimmed)) {
|
||||||
|
paragraph += ` ${trimmed}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushParagraph();
|
||||||
|
paragraph = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
return out
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTerminalTranscript(buffer: string): string {
|
||||||
|
// Stripping ANSI/OSC/DCS escape sequences and stray C0/C1 control bytes
|
||||||
|
// legitimately requires control characters in these patterns.
|
||||||
|
/* eslint-disable no-control-regex */
|
||||||
|
return String(buffer || '')
|
||||||
|
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
||||||
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||||
|
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
||||||
|
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
||||||
|
.replace(/\x1b[>=<78cDEHM]/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\r/g, '\n')
|
||||||
|
.replace(/[ \t]+$/gm, '')
|
||||||
|
.trim();
|
||||||
|
/* eslint-enable no-control-regex */
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimLeadingStartup(lines: string[]): string[] {
|
||||||
|
let index = 0;
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index] || '';
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^[╭╰│─].*[╮╯│]?$/.test(trimmed)) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^>_\s*OpenAI Codex/i.test(trimmed)) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^(model|directory):\s+/i.test(trimmed)) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return lines.slice(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushBlock(
|
||||||
|
blocks: ResponseViewerTranscriptBlock[],
|
||||||
|
kind: ResponseViewerTranscriptKind | null,
|
||||||
|
lines: string[]
|
||||||
|
): void {
|
||||||
|
if (!kind || lines.length === 0) return;
|
||||||
|
const normalizedLines =
|
||||||
|
kind === 'status'
|
||||||
|
? lines.map((line) => normalizeDividerStatusLine(line) || line.trim()).filter((line) => line.length > 0)
|
||||||
|
: lines;
|
||||||
|
const text =
|
||||||
|
kind === 'tool' || kind === 'status'
|
||||||
|
? normalizedLines
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
: normalizeWrappedText(normalizedLines);
|
||||||
|
if (!text) return;
|
||||||
|
const label = (kind.charAt(0).toUpperCase() + kind.slice(1)) as ResponseViewerTranscriptBlock['label'];
|
||||||
|
// The frontend's loadFullContext() renders via msg.role — a block without it
|
||||||
|
// lost the "You" badge on prompts and rendered every block as the agent.
|
||||||
|
blocks.push({ kind, label, role: kind === 'prompt' ? 'user' : 'assistant', text });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
|
||||||
|
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExternalCliTranscript(
|
||||||
|
buffer: string,
|
||||||
|
mode: string | null | undefined
|
||||||
|
): ResponseViewerTranscriptBlock[] {
|
||||||
|
const resolvedMode = String(mode || '');
|
||||||
|
if (!isExternalCliTranscriptMode(resolvedMode)) return [];
|
||||||
|
|
||||||
|
const cleaned = cleanTerminalTranscript(buffer);
|
||||||
|
if (!cleaned) return [];
|
||||||
|
|
||||||
|
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||||
|
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||||
|
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||||
|
let currentLines: string[] = [];
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
pushBlock(blocks, currentKind, currentLines);
|
||||||
|
currentKind = null;
|
||||||
|
currentLines = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i] ?? '';
|
||||||
|
// COD-226: within a prompt, the 2-space Codex gutter is authoritative. Blank
|
||||||
|
// lines and gutter-indented (2+ leading spaces) continuation lines stay in the
|
||||||
|
// Prompt block ahead of every structural detector below, so multiline prompts —
|
||||||
|
// including bullets, indented dividers, and literal › lines — are not
|
||||||
|
// misclassified as responses. Only a non-blank, non-gutter line ends the prompt
|
||||||
|
// and falls through (a column-0 › then opens a NEW prompt).
|
||||||
|
if (currentKind === 'prompt') {
|
||||||
|
if (!line.trim()) {
|
||||||
|
currentLines.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ {2,}\S/.test(line)) {
|
||||||
|
currentLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDividerOnlyLine(line)) {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPromptLine(line)) {
|
||||||
|
flush();
|
||||||
|
currentKind = 'prompt';
|
||||||
|
currentLines = [line.replace(/^\s*›\s*/, '').trim()];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COD-227: • Calling / • Called are always tool markers; the other action verbs
|
||||||
|
// are a tool header only when a box-drawing result tree follows on the next
|
||||||
|
// non-blank line — otherwise the verb bullet is ordinary assistant prose.
|
||||||
|
if (isToolActivityMarker(line) || (isToolVerbBullet(line) && nextNonBlankIsBoxDrawing(lines, i))) {
|
||||||
|
if (currentKind !== 'tool') flush();
|
||||||
|
currentKind = 'tool';
|
||||||
|
currentLines.push(line.trimEnd());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolContinuationLine(line, currentKind)) {
|
||||||
|
currentLines.push(line.trimEnd());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStatusLine(line, resolvedMode)) {
|
||||||
|
if (currentKind !== 'status') flush();
|
||||||
|
currentKind = 'status';
|
||||||
|
currentLines.push(line.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
currentLines.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentKind !== 'response') flush();
|
||||||
|
currentKind = 'response';
|
||||||
|
currentLines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return blocks.filter((block) => block.text.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[]): string {
|
||||||
|
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (blocks[i]?.kind === 'response') return blocks[i].text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
import {
|
import {
|
||||||
ApiErrorCode,
|
ApiErrorCode,
|
||||||
createErrorResponse,
|
createErrorResponse,
|
||||||
@@ -131,6 +132,11 @@ import {
|
|||||||
toSessionDocker,
|
toSessionDocker,
|
||||||
} from '../../docker-hosts.js';
|
} from '../../docker-hosts.js';
|
||||||
import { LRUMap } from '../../utils/lru-map.js';
|
import { LRUMap } from '../../utils/lru-map.js';
|
||||||
|
import {
|
||||||
|
getLastTranscriptResponse,
|
||||||
|
isExternalCliTranscriptMode,
|
||||||
|
parseExternalCliTranscript,
|
||||||
|
} from '../response-viewer-transcript.js';
|
||||||
|
|
||||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||||
@@ -797,54 +803,42 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check OpenCode availability if requested
|
// Check OpenCode availability if requested. The error text comes from the
|
||||||
|
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
|
||||||
|
// server PATH, login shell, common directories — same for the modes below.
|
||||||
if (body.mode === 'opencode') {
|
if (body.mode === 'opencode') {
|
||||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
|
||||||
if (!isOpenCodeAvailable()) {
|
if (!isOpenCodeAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Codex availability if requested
|
// Check Codex availability if requested
|
||||||
if (body.mode === 'codex') {
|
if (body.mode === 'codex') {
|
||||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||||
if (!isCodexAvailable()) {
|
if (!isCodexAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Gemini availability if requested
|
// Check Gemini availability if requested
|
||||||
if (body.mode === 'gemini') {
|
if (body.mode === 'gemini') {
|
||||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
if (!isGeminiAvailable()) {
|
if (!isGeminiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body.mode === 'antigravity') {
|
if (body.mode === 'antigravity') {
|
||||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||||
|
await import('../../utils/antigravity-cli-resolver.js');
|
||||||
if (!isAntigravityAvailable()) {
|
if (!isAntigravityAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body.mode === 'pi') {
|
if (body.mode === 'pi') {
|
||||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||||
if (!isPiAvailable()) {
|
if (!isPiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1898,6 +1892,22 @@ export function registerSessionRoutes(
|
|||||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode / Gemini / Antigravity / Pi render their own TUIs and write no
|
||||||
|
// Claude transcript, so the scan below finds nothing and the response viewer
|
||||||
|
// renders permanently empty for them. Segment the terminal buffer instead —
|
||||||
|
// the pane IS the transcript for these CLIs. Codex is already handled above,
|
||||||
|
// where a real rollout file is the better source.
|
||||||
|
if (isExternalCliTranscriptMode(session.mode)) {
|
||||||
|
const externalQuery = req.query as { context?: string };
|
||||||
|
const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
|
||||||
|
return {
|
||||||
|
text: getLastTranscriptResponse(blocks),
|
||||||
|
timestamp: '',
|
||||||
|
hasContext: blocks.length > 0,
|
||||||
|
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Scan ~/.claude/projects/*/ for the transcript file
|
// Scan ~/.claude/projects/*/ for the transcript file
|
||||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||||
|
|
||||||
@@ -2270,18 +2280,17 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
// Query params:
|
// Query params:
|
||||||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
// tail=<bytes> - Only return last N bytes (faster initial load)
|
||||||
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
|
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
|
||||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
||||||
|
const routeStartedAt = performance.now();
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const query = req.query as { tail?: string; full?: string };
|
const query = req.query as { tail?: string; full?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
|
||||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
// tmux scrollback so history beyond the server byte buffer can be recovered.
|
||||||
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
|
||||||
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
|
// no-param callers — keep the fast visible-frame capture.
|
||||||
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
|
|
||||||
// the fast visible-frame capture.
|
|
||||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||||
const isFullReload = query.full === '1' || query.full === 'true';
|
const isFullReload = query.full === '1' || query.full === 'true';
|
||||||
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||||||
@@ -2294,6 +2303,7 @@ export function registerSessionRoutes(
|
|||||||
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
|
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
|
||||||
// returns null when unavailable, in which case we fall back to history.
|
// returns null when unavailable, in which case we fall back to history.
|
||||||
const muxName = session.muxName;
|
const muxName = session.muxName;
|
||||||
|
const captureStartedAt = performance.now();
|
||||||
const liveMuxBuffer =
|
const liveMuxBuffer =
|
||||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||||
? ctx.mux.captureActivePaneBuffer(
|
? ctx.mux.captureActivePaneBuffer(
|
||||||
@@ -2303,6 +2313,7 @@ export function registerSessionRoutes(
|
|||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const captureFinishedAt = performance.now();
|
||||||
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||||||
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
||||||
? isFullReload
|
? isFullReload
|
||||||
@@ -2406,6 +2417,14 @@ export function registerSessionRoutes(
|
|||||||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||||||
|
|
||||||
|
const finishedAt = performance.now();
|
||||||
|
reply.header(
|
||||||
|
'Server-Timing',
|
||||||
|
`capture;dur=${(captureFinishedAt - captureStartedAt).toFixed(1)}, ` +
|
||||||
|
`prepare;dur=${(finishedAt - captureFinishedAt).toFixed(1)}, ` +
|
||||||
|
`total;dur=${(finishedAt - routeStartedAt).toFixed(1)}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
terminalBuffer: cleanBuffer,
|
terminalBuffer: cleanBuffer,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
@@ -2806,58 +2825,46 @@ export function registerSessionRoutes(
|
|||||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check OpenCode availability if requested
|
// Check OpenCode availability if requested. Error text comes from the
|
||||||
|
// resolver so it carries the resolution diagnostics; same for the modes below.
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
|
||||||
|
await import('../../utils/opencode-cli-resolver.js');
|
||||||
if (!isOpenCodeAvailable()) {
|
if (!isOpenCodeAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Codex availability if requested
|
// Check Codex availability if requested
|
||||||
if (mode === 'codex') {
|
if (mode === 'codex') {
|
||||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||||
if (!isCodexAvailable()) {
|
if (!isCodexAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Gemini availability if requested
|
// Check Gemini availability if requested
|
||||||
if (mode === 'gemini') {
|
if (mode === 'gemini') {
|
||||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
if (!isGeminiAvailable()) {
|
if (!isGeminiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Antigravity availability if requested
|
// Check Antigravity availability if requested
|
||||||
if (mode === 'antigravity') {
|
if (mode === 'antigravity') {
|
||||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||||
|
await import('../../utils/antigravity-cli-resolver.js');
|
||||||
if (!isAntigravityAvailable()) {
|
if (!isAntigravityAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Pi availability if requested
|
// Check Pi availability if requested
|
||||||
if (mode === 'pi') {
|
if (mode === 'pi') {
|
||||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||||
if (!isPiAvailable()) {
|
if (!isPiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||||
|
import { getRepositoryStatus } from '../repo-status.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||||
@@ -354,6 +355,11 @@ export function registerSystemRoutes(
|
|||||||
// Poll target for update progress — survives the restart the update triggers.
|
// Poll target for update progress — survives the restart the update triggers.
|
||||||
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
|
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
|
||||||
|
|
||||||
|
// Informational companion to the release-tag updater above: what this CHECKOUT
|
||||||
|
// looks like against its own remotes (ahead/behind, incoming commits), which a
|
||||||
|
// release tag cannot answer for a git install tracking a branch.
|
||||||
|
app.get('/api/system/repo-status', async () => getRepositoryStatus());
|
||||||
|
|
||||||
// Kick off a detached update to the latest release. Returns immediately; the
|
// Kick off a detached update to the latest release. Returns immediately; the
|
||||||
// browser then polls /api/system/update/status across the service restart.
|
// browser then polls /api/system/update/status across the service restart.
|
||||||
app.post('/api/system/update', async (_req, reply) => {
|
app.post('/api/system/update', async (_req, reply) => {
|
||||||
@@ -722,7 +728,8 @@ export function registerSystemRoutes(
|
|||||||
const merged = { ...existing, ...settingsToStore };
|
const merged = { ...existing, ...settingsToStore };
|
||||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||||
|
|
||||||
// Apply a changed tmux history-limit to all live sessions immediately.
|
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||||
|
// Already-evicted history cannot be recovered on either version.
|
||||||
if (settings.tmuxHistoryLimit !== undefined) {
|
if (settings.tmuxHistoryLimit !== undefined) {
|
||||||
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,10 +220,18 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
// An idle-prompt inbox item means "composer is waiting"; any working
|
// An idle-prompt inbox item means "composer is waiting"; any working
|
||||||
// transition means input arrived, so the item is moot. ONLY the idle
|
// transition means input arrived, so the item is moot. ONLY the idle
|
||||||
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||||
// pending permission/question dialog on it would false-clear real
|
// pending permission/question dialog on the signal ALONE would
|
||||||
// approvals (those resolve via stop / elicitation hooks / answer-time
|
// false-clear real approvals.
|
||||||
// re-capture instead).
|
|
||||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||||
|
// A permission/question dialog gets the pane-VERIFIED variant instead:
|
||||||
|
// the signal only decides when to look, `verifyStillAnswerable` re-reads
|
||||||
|
// the screen and resolves only when the dialog is really gone. Without
|
||||||
|
// this, answering a dialog in the terminal left its red "needs you" alert
|
||||||
|
// armed for the rest of the turn, because the only other staleness check
|
||||||
|
// lives in `GET /api/approvals` and nothing calls that while a page is
|
||||||
|
// open. `stop` was the first thing to clear it, which on a long turn is
|
||||||
|
// minutes away.
|
||||||
|
approvalInbox.resolveIfDialogGone(session.id);
|
||||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||||
// Full state ride-along: the home screens sort the running group on
|
// Full state ride-along: the home screens sort the running group on
|
||||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the Antigravity CLI resolver wrapper.
|
||||||
|
*/
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||||
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
type CliResolution,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null }));
|
||||||
|
|
||||||
|
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createCliExecutableResolver: (options: { binary: string; searchDirs: string[] }, host?: CliResolverHost) =>
|
||||||
|
host
|
||||||
|
? actual.createCliExecutableResolver(options, host)
|
||||||
|
: {
|
||||||
|
resolve: () => availabilityResolution.current,
|
||||||
|
diagnostics: () => ({
|
||||||
|
binary: options.binary,
|
||||||
|
processPath: '/service/bin',
|
||||||
|
shellPath: '/bin/zsh',
|
||||||
|
shellArgs: ['-l'],
|
||||||
|
searchDirs: [...options.searchDirs],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost(
|
||||||
|
options: {
|
||||||
|
processPathResult?: string | null;
|
||||||
|
loginShellResults?: Array<string | null>;
|
||||||
|
existingPaths?: string[];
|
||||||
|
} = {}
|
||||||
|
): CliResolverHost {
|
||||||
|
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||||
|
const existingPaths = new Set(options.existingPaths ?? []);
|
||||||
|
return {
|
||||||
|
processPath: '/service/bin',
|
||||||
|
shellPath: '/bin/zsh',
|
||||||
|
shellArgs: ['-l'],
|
||||||
|
findOnProcessPath: () => options.processPathResult ?? null,
|
||||||
|
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||||
|
exists: (path) => existingPaths.has(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Antigravity CLI resolver', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
availabilityResolution.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves agy from the service PATH', () => {
|
||||||
|
const binaryPath = '/service/bin/agy';
|
||||||
|
const resolver = createAntigravityResolverForTest(
|
||||||
|
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()?.directory).toBe('/service/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a common install directory', () => {
|
||||||
|
const binaryPath = join(homedir(), '.local', 'bin', 'agy');
|
||||||
|
const resolver = createAntigravityResolverForTest(createHost({ existingPaths: [binaryPath] }));
|
||||||
|
|
||||||
|
expect(resolver.resolve()?.directory).toBe(join(homedir(), '.local', 'bin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves agy found only by the login shell', () => {
|
||||||
|
const binaryPath = '/login-shell/bin/agy';
|
||||||
|
const resolver = createAntigravityResolverForTest(
|
||||||
|
createHost({ loginShellResults: [binaryPath], existingPaths: [binaryPath] })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()?.directory).toBe('/login-shell/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when agy is unavailable', () => {
|
||||||
|
const resolver = createAntigravityResolverForTest(createHost());
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a failed lookup after the backoff and caches the first successful login-shell discovery', () => {
|
||||||
|
const binaryPath = '/late-login-shell/bin/agy';
|
||||||
|
let now = 0;
|
||||||
|
const resolver = createAntigravityResolverForTest(
|
||||||
|
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||||
|
() => now
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
// A miss is negative-cached: within the backoff window nothing re-runs the
|
||||||
|
// chain (its login-shell tail is a synchronous bounded spawn in production).
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the public wrapper as available when agy resolves', () => {
|
||||||
|
availabilityResolution.current = {
|
||||||
|
binaryPath: '/service/bin/agy',
|
||||||
|
directory: '/service/bin',
|
||||||
|
source: 'process-path',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isAntigravityAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the public wrapper as unavailable when agy does not resolve', () => {
|
||||||
|
expect(isAntigravityAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,39 @@ const ASK_USER_QUESTION_FRAME = [
|
|||||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
// Both frames captured off a live Claude Code v2.1.237 pane. They are the
|
||||||
|
// discriminator behind the late-hook resolution: a modal dialog BLOCKS the
|
||||||
|
// turn, so a working line and a dialog cannot coexist. Note the dialog frame
|
||||||
|
// carries no `esc to interrupt` footer either — the dialog replaces it.
|
||||||
|
const LIVE_DIALOG_FRAME = [
|
||||||
|
'● Bash(sleep 12; echo "slept 12s")',
|
||||||
|
' ⎿ slept 12s',
|
||||||
|
'────────────────────────────────────────',
|
||||||
|
' ☐ Proceed',
|
||||||
|
'',
|
||||||
|
'Proceed?',
|
||||||
|
'',
|
||||||
|
'❯ 1. Yes',
|
||||||
|
' Go ahead and proceed.',
|
||||||
|
' 2. No',
|
||||||
|
' Do not proceed.',
|
||||||
|
' 3. Type something.',
|
||||||
|
'────────────────────────────────────────',
|
||||||
|
' 4. Chat about this',
|
||||||
|
'',
|
||||||
|
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const WORKING_FRAME = [
|
||||||
|
'● Bash(sleep 25)',
|
||||||
|
' ⎿ Tip: Use git worktrees to run multiple Claude sessions in parallel.',
|
||||||
|
'✢ Clauding… (13s · ↓ 1.4k tokens)',
|
||||||
|
'────────────────────────────────────────',
|
||||||
|
'❯',
|
||||||
|
'────────────────────────────────────────',
|
||||||
|
' ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
function collect(inbox: ApprovalInbox) {
|
function collect(inbox: ApprovalInbox) {
|
||||||
const pending: ApprovalItem[] = [];
|
const pending: ApprovalItem[] = [];
|
||||||
const updated: ApprovalItem[] = [];
|
const updated: ApprovalItem[] = [];
|
||||||
@@ -333,6 +366,186 @@ describe('ApprovalInbox', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('answered-in-the-terminal staleness', () => {
|
||||||
|
// Claude Code delays the Notification hook behind the dialog (measured 6s
|
||||||
|
// on v2.1.237, documented up to ~30s), so the 600ms re-capture routinely
|
||||||
|
// lands on a frame the user has ALREADY answered. Erasing `options` there
|
||||||
|
// made the item permanently unsweepable, because a missing `options` is how
|
||||||
|
// "we never could read this dialog" is expressed, and such items stay
|
||||||
|
// answerable on purpose. The red tab alert then survived every
|
||||||
|
// `GET /api/approvals` and every reload, clearing only on `stop`.
|
||||||
|
it('a re-capture taken after the answer does not erase parsed options', () => {
|
||||||
|
const { updated } = collect(inbox);
|
||||||
|
let frame = ASK_USER_QUESTION_FRAME;
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => frame,
|
||||||
|
});
|
||||||
|
expect(item.options).toHaveLength(5);
|
||||||
|
|
||||||
|
// Answered in the terminal before the re-capture fires.
|
||||||
|
frame = "● User answered Claude's questions:\n ⎿ · Which color do you prefer? → Red\n\n✶ Cooking… (6s)";
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
|
||||||
|
expect(updated).toHaveLength(1);
|
||||||
|
expect(inbox.getById(item.id)?.context).toContain('User answered');
|
||||||
|
expect(inbox.getById(item.id)?.options).toHaveLength(5);
|
||||||
|
// ...which keeps the staleness check conclusive instead of inconclusive.
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||||
|
expect(inbox.getById(item.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveIfDialogGone resolves a dialog answered in the terminal', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
let frame = PERMISSION_FRAME;
|
||||||
|
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||||
|
frame = 'the dialog is gone, claude is typing';
|
||||||
|
|
||||||
|
inbox.resolveIfDialogGone('s1');
|
||||||
|
|
||||||
|
expect(inbox.getById(item.id)).toBeUndefined();
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveIfDialogGone keeps a dialog that is still on screen', () => {
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => PERMISSION_FRAME,
|
||||||
|
});
|
||||||
|
inbox.resolveIfDialogGone('s1');
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveIfDialogGone never touches an idle item (that is the working signal job)', () => {
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'idle',
|
||||||
|
capture: () => 'composer is empty',
|
||||||
|
});
|
||||||
|
inbox.resolveIfDialogGone('s1');
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The late-hook hole: Claude Code fires the Notification behind the dialog,
|
||||||
|
// so a prompt answered before the hook lands produces an item whose FIRST
|
||||||
|
// capture already has no dialog in it. Nothing ever parsed, so "options
|
||||||
|
// vanished" can never fire, and `stop` had already gone by too — the red
|
||||||
|
// alert then survived reloads until the 12h TTL.
|
||||||
|
it('resolves an item that never parsed options once the pane is visibly working', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => WORKING_FRAME,
|
||||||
|
});
|
||||||
|
expect(item.options).toBeUndefined();
|
||||||
|
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||||
|
expect(inbox.getById(item.id)).toBeUndefined();
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an unreadable dialog answerable when the pane is NOT visibly working', () => {
|
||||||
|
// The conservative rule this fix must not loosen: no options and no proof
|
||||||
|
// the turn is running means "we cannot read it", not "it is gone".
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => 'some dialog shape we cannot parse',
|
||||||
|
});
|
||||||
|
expect(item.options).toBeUndefined();
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a real live-dialog frame carries no working line, so it is never false-resolved', () => {
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => LIVE_DIALOG_FRAME,
|
||||||
|
});
|
||||||
|
expect(item.options).toHaveLength(4);
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveIfDialogGone clears a late-hook item on the next working signal', () => {
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => WORKING_FRAME,
|
||||||
|
});
|
||||||
|
inbox.resolveIfDialogGone('s1');
|
||||||
|
expect(inbox.getById(item.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the delayed staleness pass resolves a late-hook item with no working signal needed', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => WORKING_FRAME,
|
||||||
|
});
|
||||||
|
expect(item.options).toBeUndefined();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(600); // re-capture: enrichment only, never resolves
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2400); // the delayed staleness pass
|
||||||
|
expect(inbox.getById(item.id)).toBeUndefined();
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dialog Ink paints late is NOT resolved by the delayed pass', () => {
|
||||||
|
// The paint race the re-capture exists for: the hook can beat Ink to the
|
||||||
|
// screen. Resolving inside that window would clear the alert for a dialog
|
||||||
|
// that was about to appear, so the frame is what decides, every time.
|
||||||
|
let frame = WORKING_FRAME;
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => frame,
|
||||||
|
});
|
||||||
|
frame = LIVE_DIALOG_FRAME; // Ink finishes painting
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
expect(inbox.getById(item.id)?.options).toHaveLength(4);
|
||||||
|
vi.advanceTimersByTime(2400);
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolving cancels both pending timers', () => {
|
||||||
|
const { updated } = collect(inbox);
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => PERMISSION_FRAME,
|
||||||
|
});
|
||||||
|
inbox.dismiss(item.id);
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(updated).toHaveLength(0);
|
||||||
|
expect(inbox.listPending()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveIfDialogGone is a no-op for a session with nothing pending', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
expect(() => inbox.resolveIfDialogGone('nobody')).not.toThrow();
|
||||||
|
expect(resolved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('stop() clears items and silences events', () => {
|
it('stop() clears items and silences events', () => {
|
||||||
const { resolved } = collect(inbox);
|
const { resolved } = collect(inbox);
|
||||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
||||||
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
createCliExecutableResolver,
|
||||||
|
createProductionCliResolverHost,
|
||||||
|
formatCliNotFoundMessage,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
// Pass-through spy on execFileSync so the vitest-hermeticity test below can
|
||||||
|
// PROVE the un-injected production host never spawns anything.
|
||||||
|
const { execFileSyncSpy } = vi.hoisted(() => ({ execFileSyncSpy: vi.fn() }));
|
||||||
|
vi.mock('node:child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||||
|
execFileSyncSpy.mockImplementation(actual.execFileSync as (...args: unknown[]) => unknown);
|
||||||
|
return { ...actual, execFileSync: execFileSyncSpy };
|
||||||
|
});
|
||||||
|
|
||||||
|
const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||||
|
const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function host(overrides: Partial<CliResolverHost> = {}): CliResolverHost {
|
||||||
|
return {
|
||||||
|
processPath: '/usr/bin:/bin',
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
findOnProcessPath: vi.fn(() => null),
|
||||||
|
findInLoginShell: vi.fn(() => null),
|
||||||
|
exists: vi.fn(() => false),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createCliExecutableResolver', () => {
|
||||||
|
it('prefers the server process PATH over common directories and the login shell', () => {
|
||||||
|
const h = host({
|
||||||
|
findOnProcessPath: vi.fn(() => '/process/bin/codex'),
|
||||||
|
findInLoginShell: vi.fn(() => '/shell/bin/codex'),
|
||||||
|
exists: vi.fn(() => true),
|
||||||
|
});
|
||||||
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({ binaryPath: '/process/bin/codex', source: 'process-path' });
|
||||||
|
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.findInLoginShell).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers common directories in order over the login shell', () => {
|
||||||
|
const h = host({
|
||||||
|
findInLoginShell: vi.fn(() => '/shell/bin/codex'),
|
||||||
|
exists: vi.fn((path) => path === '/second/bin/codex' || path === '/shell/bin/codex'),
|
||||||
|
});
|
||||||
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/first/bin', '/second/bin'] }, h);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({ binaryPath: '/second/bin/codex', source: 'common-directory' });
|
||||||
|
expect(h.exists).toHaveBeenNthCalledWith(1, '/first/bin/codex');
|
||||||
|
expect(h.exists).toHaveBeenNthCalledWith(2, '/second/bin/codex');
|
||||||
|
expect(h.findInLoginShell).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds an executable exposed only by the interactive login shell', () => {
|
||||||
|
const h = host({
|
||||||
|
findInLoginShell: vi.fn(() => '/home/u/.nvm/versions/node/v22/bin/codex'),
|
||||||
|
exists: vi.fn((path) => path === '/home/u/.nvm/versions/node/v22/bin/codex'),
|
||||||
|
});
|
||||||
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath: '/home/u/.nvm/versions/node/v22/bin/codex',
|
||||||
|
directory: '/home/u/.nvm/versions/node/v22/bin',
|
||||||
|
source: 'login-shell',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues after a validator rejects an earlier candidate', () => {
|
||||||
|
const h = host({
|
||||||
|
findOnProcessPath: vi.fn(() => '/usr/bin/pi'),
|
||||||
|
findInLoginShell: vi.fn(() => '/home/u/.npm/bin/pi'),
|
||||||
|
exists: vi.fn(() => true),
|
||||||
|
});
|
||||||
|
const resolver = createCliExecutableResolver(
|
||||||
|
{
|
||||||
|
binary: 'pi',
|
||||||
|
searchDirs: [],
|
||||||
|
validateCandidate: (path) =>
|
||||||
|
path.includes('.npm') ? { accepted: true, metadata: '0.84.1' } : { accepted: false },
|
||||||
|
},
|
||||||
|
h
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath: '/home/u/.npm/bin/pi',
|
||||||
|
source: 'login-shell',
|
||||||
|
metadata: '0.84.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches success, and retries a miss only after the backoff elapses', () => {
|
||||||
|
let now = 0;
|
||||||
|
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex');
|
||||||
|
const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') });
|
||||||
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
// Within the backoff window the miss is answered from the negative cache:
|
||||||
|
// the chain — whose login-shell tail is a synchronous 5s-bounded spawn —
|
||||||
|
// must NOT re-run per call, or a missing CLI stalls every status request.
|
||||||
|
now = cliResolveRetryDelayMs(1) - 1;
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Once the backoff elapses the retry runs, so installing a CLI while the
|
||||||
|
// server is up is still picked up without a restart.
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
|
||||||
|
let now = 0;
|
||||||
|
const findInLoginShell = vi.fn(() => null);
|
||||||
|
const resolver = createCliExecutableResolver(
|
||||||
|
{ binary: 'codex', searchDirs: [], now: () => now },
|
||||||
|
host({ findInLoginShell })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cliResolveRetryDelayMs(0)).toBe(0);
|
||||||
|
expect(cliResolveRetryDelayMs(1)).toBe(60_000);
|
||||||
|
expect(cliResolveRetryDelayMs(2)).toBe(120_000);
|
||||||
|
expect(cliResolveRetryDelayMs(3)).toBe(240_000);
|
||||||
|
expect(cliResolveRetryDelayMs(4)).toBe(300_000);
|
||||||
|
expect(cliResolveRetryDelayMs(60)).toBe(300_000);
|
||||||
|
|
||||||
|
// Consecutive misses stack: after the second miss the SECOND delay applies.
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
now += cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||||
|
now += cliResolveRetryDelayMs(2) - 1;
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||||
|
now += 1;
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe binary names', () => {
|
||||||
|
const h = host();
|
||||||
|
|
||||||
|
expect(() => createCliExecutableResolver({ binary: 'codex;id', searchDirs: [] }, h)).toThrow(
|
||||||
|
'Unsafe CLI binary name'
|
||||||
|
);
|
||||||
|
expect(() => createCliExecutableResolver({ binary: '../codex', searchDirs: [] }, h)).toThrow(
|
||||||
|
'Unsafe CLI binary name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects relative and nonexistent candidates', () => {
|
||||||
|
let now = 0;
|
||||||
|
const findInLoginShell = vi
|
||||||
|
.fn<() => string | null>()
|
||||||
|
.mockReturnValueOnce('relative/codex')
|
||||||
|
.mockReturnValue('/missing/codex');
|
||||||
|
const h = host({ findInLoginShell, exists: vi.fn(() => false) });
|
||||||
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.exists).toHaveBeenCalledWith('/missing/codex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCliNotFoundMessage', () => {
|
||||||
|
it('includes only the base install hint and bounded resolution diagnostics', () => {
|
||||||
|
const base = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||||
|
const diagnostics = {
|
||||||
|
binary: 'codex',
|
||||||
|
processPath: '/usr/bin:/bin',
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
searchDirs: ['/home/u/.local/bin', '/usr/local/bin'],
|
||||||
|
API_KEY: 'super-secret',
|
||||||
|
};
|
||||||
|
const message = formatCliNotFoundMessage(base, diagnostics);
|
||||||
|
|
||||||
|
expect(message).toContain(base);
|
||||||
|
expect(message).toContain('Server PATH: /usr/bin:/bin');
|
||||||
|
expect(message).toContain('Login shell: /bin/bash -i -l');
|
||||||
|
expect(message).toContain('Checked directories: /home/u/.local/bin, /usr/local/bin');
|
||||||
|
expect(message).not.toContain('API_KEY');
|
||||||
|
expect(message).not.toContain('super-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks empty diagnostic values without dumping arbitrary environment data', () => {
|
||||||
|
const message = formatCliNotFoundMessage('Missing CLI', {
|
||||||
|
binary: 'codex',
|
||||||
|
processPath: '',
|
||||||
|
shellPath: '',
|
||||||
|
shellArgs: [],
|
||||||
|
searchDirs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message).toBe('Missing CLI\nServer PATH: (empty)\nLogin shell: (none)\nChecked directories: (none)');
|
||||||
|
expect(message).not.toContain('HOME=');
|
||||||
|
expect(message).not.toContain('TOKEN=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens control characters and bounds every diagnostic field', () => {
|
||||||
|
const pathological = `first\r\nforged label: value\u0000${'x'.repeat(10_000)}`;
|
||||||
|
const message = formatCliNotFoundMessage('Missing CLI', {
|
||||||
|
binary: 'codex',
|
||||||
|
processPath: pathological,
|
||||||
|
shellPath: pathological,
|
||||||
|
shellArgs: [pathological],
|
||||||
|
searchDirs: [pathological, pathological],
|
||||||
|
});
|
||||||
|
const lines = message.split('\n');
|
||||||
|
|
||||||
|
expect(lines).toHaveLength(4);
|
||||||
|
expect(lines[1]).toMatch(/^Server PATH: first forged label: value x+…$/);
|
||||||
|
expect(lines[2]).toMatch(/^Login shell: first forged label: value x+…$/);
|
||||||
|
expect(lines[3]).toMatch(/^Checked directories: first forged label: value x+…$/);
|
||||||
|
expect(lines.slice(1).every((line) => line.length <= 1_050)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createProductionCliResolverHost', () => {
|
||||||
|
// Hermeticity gate (the guards PR #329 deleted, restored shared): under
|
||||||
|
// vitest an un-injected host must neither scan the machine nor spawn a login
|
||||||
|
// shell — route tests hitting the per-CLI status endpoints would otherwise
|
||||||
|
// walk the real PATH and execute real binaries on whatever box runs the suite.
|
||||||
|
it('never scans the machine or spawns a login shell under vitest without injected IO', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-vitest-gate-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
writeFileSync(join(root, 'codex'), '#!/bin/sh\n');
|
||||||
|
chmodSync(join(root, 'codex'), 0o755);
|
||||||
|
execFileSyncSpy.mockClear();
|
||||||
|
|
||||||
|
const gatedHost = createProductionCliResolverHost({
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real, executable candidate is invisible: the filesystem predicate is inert.
|
||||||
|
expect(gatedHost.findOnProcessPath('codex')).toBeNull();
|
||||||
|
expect(gatedHost.exists(join(root, 'codex'))).toBe(false);
|
||||||
|
// The login-shell step yields nothing and never reaches execFileSync.
|
||||||
|
expect(gatedHost.findInLoginShell('codex')).toBeNull();
|
||||||
|
expect(execFileSyncSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The same fixture through the test-only real-IO opt-in IS found, proving
|
||||||
|
// the nulls above come from the vitest gate rather than from the fixture.
|
||||||
|
const optedInHost = createProductionCliResolverHost({
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand: () => '',
|
||||||
|
allowRealIoUnderVitest: true,
|
||||||
|
});
|
||||||
|
expect(optedInHost.findOnProcessPath('codex')).toBe(join(root, 'codex'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves through injected IO hooks under vitest (injection is the opt-in)', () => {
|
||||||
|
const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`);
|
||||||
|
const productionHost = createProductionCliResolverHost({
|
||||||
|
processPath: '',
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand,
|
||||||
|
isExecutableFile: () => true,
|
||||||
|
});
|
||||||
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath: '/home/u/.nvm/bin/codex',
|
||||||
|
source: 'login-shell',
|
||||||
|
});
|
||||||
|
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches the captured process PATH directly in directory order without running a command', () => {
|
||||||
|
const runCommand = vi.fn(() => '');
|
||||||
|
const isExecutableFile = vi.fn((path: string) => path === '/second/bin/codex');
|
||||||
|
const productionHost = createProductionCliResolverHost({
|
||||||
|
processPath: '/first/bin:/second/bin:/third/bin',
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand,
|
||||||
|
isExecutableFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(productionHost.findOnProcessPath('codex')).toBe('/second/bin/codex');
|
||||||
|
expect(isExecutableFile).toHaveBeenNthCalledWith(1, '/first/bin/codex');
|
||||||
|
expect(isExecutableFile).toHaveBeenNthCalledWith(2, '/second/bin/codex');
|
||||||
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only executable regular files with the production predicate', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-resolver-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
const executableDirectory = join(root, 'executable');
|
||||||
|
const plainDirectory = join(root, 'plain');
|
||||||
|
const directoryCandidate = join(root, 'directory');
|
||||||
|
mkdirSync(executableDirectory);
|
||||||
|
mkdirSync(plainDirectory);
|
||||||
|
mkdirSync(directoryCandidate);
|
||||||
|
writeFileSync(join(executableDirectory, 'codex'), '#!/bin/sh\n');
|
||||||
|
chmodSync(join(executableDirectory, 'codex'), 0o755);
|
||||||
|
writeFileSync(join(plainDirectory, 'codex'), '#!/bin/sh\n');
|
||||||
|
mkdirSync(join(directoryCandidate, 'codex'));
|
||||||
|
const productionHost = createProductionCliResolverHost({
|
||||||
|
processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'),
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
// This test exists to exercise the REAL executable-regular-file predicate
|
||||||
|
// against its own temp fixtures, so it opts out of the vitest inert-IO
|
||||||
|
// gate; the stubbed runCommand keeps the login-shell path inert anyway.
|
||||||
|
runCommand: () => '',
|
||||||
|
allowRealIoUnderVitest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the resolved shell, allowlisted args, tagged command, and bounded timeout', () => {
|
||||||
|
const runCommand = vi.fn(() =>
|
||||||
|
['/profile/absolute-noise', BEGIN_MARKER, '/home/u/.nvm/bin/codex', END_MARKER, '/exit-trap/absolute-noise'].join(
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const productionHost = createProductionCliResolverHost({
|
||||||
|
processPath: '',
|
||||||
|
shellPath: '/usr/bin/fish',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand,
|
||||||
|
isExecutableFile: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(productionHost.findInLoginShell('codex')).toBe('/home/u/.nvm/bin/codex');
|
||||||
|
expect(runCommand).toHaveBeenCalledWith(
|
||||||
|
'/usr/bin/fish',
|
||||||
|
['-i', '-l', '-c', `printf '%s\\n' '${BEGIN_MARKER}'; command -v -- codex; printf '%s\\n' '${END_MARKER}'`],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// SIGKILL is load-bearing: interactive bash ignores SIGTERM, and
|
||||||
|
// execFileSync's timeout only sends the signal, then keeps waiting.
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['mismatched basename', `${BEGIN_MARKER}\n/opt/bin/not-codex\n${END_MARKER}`],
|
||||||
|
['missing begin marker', `/opt/bin/codex\n${END_MARKER}`],
|
||||||
|
['missing end marker', `${BEGIN_MARKER}\n/opt/bin/codex`],
|
||||||
|
['absolute output outside markers', `/profile/codex\n${BEGIN_MARKER}\nrelative/codex\n${END_MARKER}\n/exit/codex`],
|
||||||
|
])('rejects malformed tagged shell output: %s', (_name, output) => {
|
||||||
|
const productionHost = createProductionCliResolverHost({
|
||||||
|
processPath: '',
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand: () => output,
|
||||||
|
isExecutableFile: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(productionHost.findInLoginShell('codex')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the shell command throws', () => {
|
||||||
|
const productionHost = createProductionCliResolverHost({
|
||||||
|
processPath: '',
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand: () => {
|
||||||
|
throw new Error('exit 1');
|
||||||
|
},
|
||||||
|
isExecutableFile: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(productionHost.findInLoginShell('codex')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
|
|||||||
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
|
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
|
||||||
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
|
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
|
||||||
const rewriteDecision = source.indexOf(
|
const rewriteDecision = source.indexOf(
|
||||||
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
|
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer',
|
||||||
selectStart
|
selectStart
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,7 +58,9 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
|
|||||||
// Snapshot restore must NOT short-circuit the canonical fetch.
|
// Snapshot restore must NOT short-circuit the canonical fetch.
|
||||||
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
|
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
|
||||||
expect(postSnapshotRestore).toContain('restoredSnapshot');
|
expect(postSnapshotRestore).toContain('restoredSnapshot');
|
||||||
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
expect(postSnapshotRestore).toContain(
|
||||||
|
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
|
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
|
||||||
@@ -71,8 +73,8 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
|
|||||||
expect(cacheRestore).toBeGreaterThan(-1);
|
expect(cacheRestore).toBeGreaterThan(-1);
|
||||||
expect(busyClear).toBeGreaterThan(cacheRestore);
|
expect(busyClear).toBeGreaterThan(cacheRestore);
|
||||||
expect(needsRewrite).toBeGreaterThan(busyClear);
|
expect(needsRewrite).toBeGreaterThan(busyClear);
|
||||||
expect(replayBlock).toContain('clearedForBusy');
|
expect(replayBlock).toContain('clearedBeforeFresh');
|
||||||
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
expect(replayBlock).toContain('restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
|
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
|
||||||
|
|||||||
@@ -122,6 +122,18 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
|||||||
expect(app).not.toContain('earlier output truncated for performance');
|
expect(app).not.toContain('earlier output truncated for performance');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
|
||||||
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||||
|
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||||
|
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
|
||||||
|
expect(app).toContain('fetch(`/api/sessions/${sessionId}/terminal?full=1`)');
|
||||||
|
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
||||||
|
expect(app).toContain("if (session?.mode === 'shell')");
|
||||||
|
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
|
||||||
|
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the banner through textContent, never innerHTML', () => {
|
it('renders the banner through textContent, never innerHTML', () => {
|
||||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
const start = app.indexOf('_renderHistoryTruncationBanner() {');
|
const start = app.indexOf('_renderHistoryTruncationBanner() {');
|
||||||
|
|||||||
+42
-11
@@ -5,7 +5,7 @@
|
|||||||
* composition/keydown/input event sequences against a stub textarea.
|
* composition/keydown/input event sequences against a stub textarea.
|
||||||
* Focus: the intermittent "Chinese characters silently lost" failure modes —
|
* Focus: the intermittent "Chinese characters silently lost" failure modes —
|
||||||
* stuck composition state, deferred flush racing the next composition, and
|
* stuck composition state, deferred flush racing the next composition, and
|
||||||
* the keydown-echo suppression window swallowing a real IME commit.
|
* physical-key punctuation transformed into full-width IME output.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
@@ -116,6 +116,36 @@ describe('CJK input module', () => {
|
|||||||
expect(textarea.value).toBe(PHANTOM);
|
expect(textarea.value).toBe(PHANTOM);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets the IME transform printable keys before sending full-width punctuation', () => {
|
||||||
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
const committed = Array.from(',。!?;:“”、《》、()');
|
||||||
|
const physicalKeys = [',', '.', '!', '?', ';', ':', '"', '"', '\\', '<', '>', '\\', '(', ')'];
|
||||||
|
|
||||||
|
textarea.fire('compositionstart');
|
||||||
|
textarea.value = PHANTOM + '中文';
|
||||||
|
textarea.fire('input', { isComposing: true, inputType: 'insertCompositionText' });
|
||||||
|
textarea.fire('compositionend');
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
for (const [index, punctuation] of committed.entries()) {
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
textarea.fire('keydown', {
|
||||||
|
key: physicalKeys[index],
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault,
|
||||||
|
});
|
||||||
|
expect(preventDefault).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
textarea.value = PHANTOM + punctuation;
|
||||||
|
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(sent).toEqual(['中文', ...committed]);
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers committed text when compositionend never fires (stuck composition)', () => {
|
it('recovers committed text when compositionend never fires (stuck composition)', () => {
|
||||||
const { textarea, sent } = loadCjkHarness();
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
|
||||||
@@ -124,6 +154,7 @@ describe('CJK input module', () => {
|
|||||||
textarea.value = PHANTOM + '你好';
|
textarea.value = PHANTOM + '你好';
|
||||||
// The commit arrives as a plain input event outside composition.
|
// The commit arrives as a plain input event outside composition.
|
||||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
vi.advanceTimersByTime(200);
|
vi.advanceTimersByTime(200);
|
||||||
|
|
||||||
expect(sent).toEqual(['你好']);
|
expect(sent).toEqual(['你好']);
|
||||||
@@ -151,30 +182,28 @@ describe('CJK input module', () => {
|
|||||||
expect(sent).toEqual(['你好世界']);
|
expect(sent).toEqual(['你好世界']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not discard an IME commit landing inside the keydown echo window', () => {
|
it('sends the transformed IME commit that follows a printable keydown', () => {
|
||||||
const { textarea, sent } = loadCjkHarness();
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
|
||||||
// English char goes out immediately via keydown.
|
vi.advanceTimersByTime(1000);
|
||||||
|
// The physical key is not committed text and must not be sent by itself.
|
||||||
textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false });
|
textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false });
|
||||||
expect(sent).toEqual(['a']);
|
expect(sent).toEqual([]);
|
||||||
|
|
||||||
// Within 100ms the IME commits Chinese via a bare input event.
|
// The browser/IME supplies the canonical text in the following input.
|
||||||
vi.advanceTimersByTime(50);
|
vi.advanceTimersByTime(50);
|
||||||
textarea.value = PHANTOM + '你好';
|
textarea.value = PHANTOM + '你好';
|
||||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||||
vi.advanceTimersByTime(200);
|
|
||||||
|
|
||||||
expect(sent).toEqual(['a', '你好']);
|
expect(sent).toEqual(['你好']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still suppresses the true textarea echo of a keydown-sent character', () => {
|
it('sends a printable physical key exactly once after its input event', () => {
|
||||||
const { textarea, sent } = loadCjkHarness();
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
|
||||||
textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false });
|
textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false });
|
||||||
expect(sent).toEqual(['a']);
|
expect(sent).toEqual([]);
|
||||||
|
|
||||||
// Third-party IME ignored preventDefault — the same char echoes into
|
|
||||||
// the textarea. It must be dropped, not sent twice.
|
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
textarea.value = PHANTOM + 'a';
|
textarea.value = PHANTOM + 'a';
|
||||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||||
@@ -242,6 +271,8 @@ describe('CJK input module', () => {
|
|||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
||||||
textarea.fire('keydown', { key: '囍', ctrlKey: false, altKey: false, metaKey: false });
|
textarea.fire('keydown', { key: '囍', ctrlKey: false, altKey: false, metaKey: false });
|
||||||
|
textarea.value = PHANTOM + '囍';
|
||||||
|
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||||
textarea.value = PHANTOM + '秘密';
|
textarea.value = PHANTOM + '秘密';
|
||||||
textarea.fire('blur');
|
textarea.fire('blur');
|
||||||
expect(sent).toEqual(['秘密口令', '囍']);
|
expect(sent).toEqual(['秘密口令', '囍']);
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the Pi CLI resolver wrapper.
|
||||||
|
*
|
||||||
|
* Pi is the resolver with a version probe: `pi` is a short, generic binary
|
||||||
|
* name, so a resolved path is only accepted once `pi --version` prints a
|
||||||
|
* semver-shaped string. The probe EXECUTES the candidate, which is exactly why
|
||||||
|
* it must never run under vitest — the hermeticity test below pins that gate
|
||||||
|
* with a real executable fixture that would make the test fail loudly if the
|
||||||
|
* gate were deleted again (as PR #329 once did).
|
||||||
|
*/
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPiResolverForTest } from '../src/utils/pi-cli-resolver.js';
|
||||||
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
createProductionCliResolverHost,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost(
|
||||||
|
options: {
|
||||||
|
processPathResult?: string | null;
|
||||||
|
loginShellResults?: Array<string | null>;
|
||||||
|
existingPaths?: string[];
|
||||||
|
} = {}
|
||||||
|
): CliResolverHost {
|
||||||
|
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||||
|
const existingPaths = new Set(options.existingPaths ?? []);
|
||||||
|
return {
|
||||||
|
processPath: '/service/bin',
|
||||||
|
shellPath: '/bin/zsh',
|
||||||
|
shellArgs: ['-l'],
|
||||||
|
findOnProcessPath: () => options.processPathResult ?? null,
|
||||||
|
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||||
|
exists: (path) => existingPaths.has(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Pi CLI resolver', () => {
|
||||||
|
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||||
|
const binaryPath = '/service/bin/pi';
|
||||||
|
const probe = vi.fn(() => '0.84.1');
|
||||||
|
const resolver = createPiResolverForTest(
|
||||||
|
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath,
|
||||||
|
directory: '/service/bin',
|
||||||
|
source: 'process-path',
|
||||||
|
metadata: '0.84.1',
|
||||||
|
});
|
||||||
|
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||||
|
// An unrelated `pi` on the service PATH (probe returns null) must not mask
|
||||||
|
// the real coding agent found by the login shell.
|
||||||
|
const impostor = '/service/bin/pi';
|
||||||
|
const genuine = '/login-shell/bin/pi';
|
||||||
|
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.84.1' : null));
|
||||||
|
const resolver = createPiResolverForTest(
|
||||||
|
createHost({
|
||||||
|
processPathResult: impostor,
|
||||||
|
loginShellResults: [genuine],
|
||||||
|
existingPaths: [impostor, genuine],
|
||||||
|
}),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '0.84.1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||||
|
const binaryPath = '/late/bin/pi';
|
||||||
|
let now = 0;
|
||||||
|
const probe = vi.fn(() => '0.84.1');
|
||||||
|
const resolver = createPiResolverForTest(
|
||||||
|
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||||
|
probe,
|
||||||
|
() => now
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()?.metadata).toBe('0.84.1');
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never executes a pi candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||||
|
// A REAL executable fixture that prints a valid version. If the guard in
|
||||||
|
// probePiVersion is ever removed again, the probe runs this script, the
|
||||||
|
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||||
|
// behavior rather than by source text. (The suites must never execute
|
||||||
|
// whatever `pi` binary the machine running them happens to carry.)
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-pi-vitest-gate-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
const binaryPath = join(root, 'pi');
|
||||||
|
writeFileSync(binaryPath, '#!/bin/sh\necho 0.99.0\n');
|
||||||
|
chmodSync(binaryPath, 0o755);
|
||||||
|
const hostOptions = {
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'] as string[],
|
||||||
|
runCommand: () => '',
|
||||||
|
isExecutableFile: (path: string) => path === binaryPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default (ambient) probe: the candidate is found but never executed, so
|
||||||
|
// the VITEST gate reports it unusable and resolution misses.
|
||||||
|
const gated = createPiResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||||
|
expect(gated.resolve()).toBeNull();
|
||||||
|
|
||||||
|
// Control: identical setup with an injected probe resolves, proving the
|
||||||
|
// null above comes from the gate, not from the fixture or the host.
|
||||||
|
const control = createPiResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||||
|
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
-5
@@ -129,6 +129,36 @@ describe('RalphLoop', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reschedule loop (regression)', () => {
|
||||||
|
// tick() is the only path that calls setRalphLoopState with lastCheckAt and
|
||||||
|
// no status field; start() carries status:'running', stop() status:'stopped'.
|
||||||
|
const countTicks = () =>
|
||||||
|
(mockState.store.setRalphLoopState as any).mock.calls.filter(
|
||||||
|
(c: any[]) => c[0]?.lastCheckAt !== undefined && c[0]?.status === undefined
|
||||||
|
).length;
|
||||||
|
|
||||||
|
it('keeps rescheduling past the second tick', async () => {
|
||||||
|
// Real timers + a tiny poll interval: tick()'s async internals settle
|
||||||
|
// naturally on the event loop, avoiding fake-timer microtask fragility.
|
||||||
|
vi.useRealTimers();
|
||||||
|
loop.destroy();
|
||||||
|
// Keep the loop in the "running, should keep polling" state so it actually
|
||||||
|
// exercises the reschedule path: min duration unreached + autoGenerate on
|
||||||
|
// makes shouldStop() false (idle sessions are mocked empty, so nothing is
|
||||||
|
// actually generated). Otherwise an empty queue self-stops after 1 tick.
|
||||||
|
loop = new RalphLoop({ pollIntervalMs: 5, minDurationMs: 60_000, autoGenerateTasks: true });
|
||||||
|
|
||||||
|
await loop.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80)); // ~16 poll intervals
|
||||||
|
loop.stop();
|
||||||
|
|
||||||
|
// The bug: the loopTimer handle is never nulled in the setTimeout callback,
|
||||||
|
// so the `loopTimer === null` reschedule guard is false after the first fire
|
||||||
|
// and the loop dies at exactly 2 ticks. Fixed -> many ticks.
|
||||||
|
expect(countTicks()).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('initial state', () => {
|
describe('initial state', () => {
|
||||||
it('should start in stopped status', () => {
|
it('should start in stopped status', () => {
|
||||||
expect(loop.status).toBe('stopped');
|
expect(loop.status).toBe('stopped');
|
||||||
@@ -296,11 +326,7 @@ describe('RalphLoop', () => {
|
|||||||
describe('getStats', () => {
|
describe('getStats', () => {
|
||||||
it('should return complete stats object', async () => {
|
it('should return complete stats object', async () => {
|
||||||
const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false };
|
const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false };
|
||||||
mockState.taskQueue.tasks = [
|
mockState.taskQueue.tasks = [{ id: '1', status: 'pending' }, mockRunningTask, { id: '3', status: 'completed' }];
|
||||||
{ id: '1', status: 'pending' },
|
|
||||||
mockRunningTask,
|
|
||||||
{ id: '3', status: 'completed' },
|
|
||||||
];
|
|
||||||
mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]);
|
mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]);
|
||||||
|
|
||||||
await loop.start();
|
await loop.start();
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for the repository-status pure helpers: ahead/behind
|
||||||
|
* + log + symref parsing, env parsing, the remote-set / role / tracking-remote
|
||||||
|
* decisions, credential redaction of the returned fields, and the single-flight
|
||||||
|
* TTL cache that keeps the async status computation off the git hot path.
|
||||||
|
* No IO, no git, no port — safe to run individually.
|
||||||
|
*
|
||||||
|
* npm test -- test/repo-status.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseAheadBehind,
|
||||||
|
parseLogLines,
|
||||||
|
parseSymrefDefaultBranch,
|
||||||
|
parseRemotesEnv,
|
||||||
|
parseTrackingRemote,
|
||||||
|
resolveRemoteSet,
|
||||||
|
roleForRemote,
|
||||||
|
isSafeGitPositional,
|
||||||
|
redactRemoteStatus,
|
||||||
|
createSingleFlightCache,
|
||||||
|
} from '../src/web/repo-status.js';
|
||||||
|
import { redactGitCredentials } from '../src/git-clone.js';
|
||||||
|
import type { RepoRemoteStatus } from '../src/types/update.js';
|
||||||
|
|
||||||
|
describe('parseAheadBehind', () => {
|
||||||
|
it('parses tab-separated left/right counts as ahead/behind', () => {
|
||||||
|
expect(parseAheadBehind('3\t14')).toEqual({ ahead: 3, behind: 14 });
|
||||||
|
});
|
||||||
|
it('parses space-separated counts', () => {
|
||||||
|
expect(parseAheadBehind('0 0')).toEqual({ ahead: 0, behind: 0 });
|
||||||
|
});
|
||||||
|
it('tolerates surrounding whitespace/newline', () => {
|
||||||
|
expect(parseAheadBehind(' 5 2 \n')).toEqual({ ahead: 5, behind: 2 });
|
||||||
|
});
|
||||||
|
it('returns null on malformed output', () => {
|
||||||
|
expect(parseAheadBehind('')).toBeNull();
|
||||||
|
expect(parseAheadBehind('abc')).toBeNull();
|
||||||
|
expect(parseAheadBehind('1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseLogLines', () => {
|
||||||
|
it('splits each line into sha + subject', () => {
|
||||||
|
const out = 'a1b2c3 fix: thing\nd4e5f6 COD-9 add digest';
|
||||||
|
expect(parseLogLines(out)).toEqual([
|
||||||
|
{ sha: 'a1b2c3', subject: 'fix: thing' },
|
||||||
|
{ sha: 'd4e5f6', subject: 'COD-9 add digest' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('handles a subject with no space (sha only)', () => {
|
||||||
|
expect(parseLogLines('deadbee')).toEqual([{ sha: 'deadbee', subject: '' }]);
|
||||||
|
});
|
||||||
|
it('ignores blank lines', () => {
|
||||||
|
expect(parseLogLines('\n \nabc123 hi\n')).toEqual([{ sha: 'abc123', subject: 'hi' }]);
|
||||||
|
});
|
||||||
|
it('returns [] for empty input', () => {
|
||||||
|
expect(parseLogLines('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSymrefDefaultBranch', () => {
|
||||||
|
it('extracts the default branch from ls-remote --symref output', () => {
|
||||||
|
const out = 'ref: refs/heads/master\tHEAD\n0123abc\tHEAD';
|
||||||
|
expect(parseSymrefDefaultBranch(out)).toBe('master');
|
||||||
|
});
|
||||||
|
it('handles main', () => {
|
||||||
|
expect(parseSymrefDefaultBranch('ref: refs/heads/main\tHEAD')).toBe('main');
|
||||||
|
});
|
||||||
|
it('returns null when no symref line present', () => {
|
||||||
|
expect(parseSymrefDefaultBranch('0123abc\tHEAD')).toBeNull();
|
||||||
|
});
|
||||||
|
it('rejects a hostile branch that would smuggle a git flag (argv injection)', () => {
|
||||||
|
// A malicious remote sets HEAD to a `-`-leading "branch"; the capture must
|
||||||
|
// not start with `-`, so this yields null rather than `--upload-pack=...`.
|
||||||
|
expect(parseSymrefDefaultBranch('ref: refs/heads/--upload-pack=touch\tHEAD')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSafeGitPositional', () => {
|
||||||
|
it('accepts normal remote/branch names', () => {
|
||||||
|
expect(isSafeGitPositional('origin')).toBe(true);
|
||||||
|
expect(isSafeGitPositional('feature/foo')).toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects flag-injecting and empty values', () => {
|
||||||
|
expect(isSafeGitPositional('--upload-pack=touch /tmp/x')).toBe(false);
|
||||||
|
expect(isSafeGitPositional('-x')).toBe(false);
|
||||||
|
expect(isSafeGitPositional('')).toBe(false);
|
||||||
|
expect(isSafeGitPositional(null)).toBe(false);
|
||||||
|
expect(isSafeGitPositional(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRemotesEnv', () => {
|
||||||
|
it('splits comma list, trims, drops empties', () => {
|
||||||
|
expect(parseRemotesEnv('origin, bitbucket ,, fork')).toEqual(['origin', 'bitbucket', 'fork']);
|
||||||
|
});
|
||||||
|
it('returns [] for null/undefined/empty', () => {
|
||||||
|
expect(parseRemotesEnv(null)).toEqual([]);
|
||||||
|
expect(parseRemotesEnv(undefined)).toEqual([]);
|
||||||
|
expect(parseRemotesEnv('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRemoteSet', () => {
|
||||||
|
it('defaults to tracking-remote first, then origin (the maintainer-fork layout)', () => {
|
||||||
|
// local branch tracks bitbucket; origin is the canonical upstream.
|
||||||
|
const set = resolveRemoteSet({
|
||||||
|
existingRemotes: ['bitbucket', 'fork', 'origin'],
|
||||||
|
trackingRemote: 'bitbucket',
|
||||||
|
envRemotes: [],
|
||||||
|
});
|
||||||
|
expect(set).toEqual(['bitbucket', 'origin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses to a single entry when origin IS the tracking remote', () => {
|
||||||
|
const set = resolveRemoteSet({
|
||||||
|
existingRemotes: ['origin'],
|
||||||
|
trackingRemote: 'origin',
|
||||||
|
envRemotes: [],
|
||||||
|
});
|
||||||
|
expect(set).toEqual(['origin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to just origin when there is no tracking remote', () => {
|
||||||
|
const set = resolveRemoteSet({
|
||||||
|
existingRemotes: ['origin', 'fork'],
|
||||||
|
trackingRemote: null,
|
||||||
|
envRemotes: [],
|
||||||
|
});
|
||||||
|
expect(set).toEqual(['origin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors CODEMAN_UPDATE_REMOTES order and filters to existing remotes', () => {
|
||||||
|
const set = resolveRemoteSet({
|
||||||
|
existingRemotes: ['origin', 'bitbucket', 'fork'],
|
||||||
|
trackingRemote: 'bitbucket',
|
||||||
|
envRemotes: ['fork', 'origin', 'ghost'],
|
||||||
|
});
|
||||||
|
expect(set).toEqual(['fork', 'origin']); // 'ghost' dropped (does not exist)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when env names none of the existing remotes', () => {
|
||||||
|
const set = resolveRemoteSet({
|
||||||
|
existingRemotes: ['origin'],
|
||||||
|
trackingRemote: null,
|
||||||
|
envRemotes: ['nope'],
|
||||||
|
});
|
||||||
|
expect(set).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roleForRemote', () => {
|
||||||
|
it('labels the tracking remote as tracking even if named origin', () => {
|
||||||
|
expect(roleForRemote('origin', 'origin')).toBe('tracking');
|
||||||
|
expect(roleForRemote('bitbucket', 'bitbucket')).toBe('tracking');
|
||||||
|
});
|
||||||
|
it('labels origin/upstream (when not tracking) as upstream', () => {
|
||||||
|
expect(roleForRemote('origin', 'bitbucket')).toBe('upstream');
|
||||||
|
expect(roleForRemote('upstream', 'origin')).toBe('upstream');
|
||||||
|
});
|
||||||
|
it('labels anything else as other', () => {
|
||||||
|
expect(roleForRemote('fork', 'bitbucket')).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTrackingRemote', () => {
|
||||||
|
it('extracts the remote name from a remote-tracking short ref', () => {
|
||||||
|
expect(parseTrackingRemote('origin/master')).toBe('origin');
|
||||||
|
expect(parseTrackingRemote('bitbucket/feature/nested')).toBe('bitbucket');
|
||||||
|
});
|
||||||
|
it('returns null for a LOCAL-branch upstream (ref with no slash)', () => {
|
||||||
|
// `git branch -u otherbranch` makes @{upstream} a bare branch name; the old
|
||||||
|
// slice(0, indexOf('/')) turned "master" into "maste" here.
|
||||||
|
expect(parseTrackingRemote('master')).toBeNull();
|
||||||
|
expect(parseTrackingRemote('main')).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns null for null/empty/degenerate refs', () => {
|
||||||
|
expect(parseTrackingRemote(null)).toBeNull();
|
||||||
|
expect(parseTrackingRemote(undefined)).toBeNull();
|
||||||
|
expect(parseTrackingRemote('')).toBeNull();
|
||||||
|
expect(parseTrackingRemote('/leading-slash')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('credential redaction', () => {
|
||||||
|
it('redactGitCredentials masks scheme://user:secret@host pairs', () => {
|
||||||
|
expect(redactGitCredentials('https://user:ghp_token123@github.com/o/r.git')).toBe(
|
||||||
|
'https://***:***@github.com/o/r.git'
|
||||||
|
);
|
||||||
|
expect(redactGitCredentials('plain text, no url')).toBe('plain text, no url');
|
||||||
|
expect(redactGitCredentials('https://github.com/o/r.git')).toBe('https://github.com/o/r.git');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redactRemoteStatus masks the url field', () => {
|
||||||
|
const status: RepoRemoteStatus = {
|
||||||
|
name: 'origin',
|
||||||
|
url: 'https://alice:s3cret@example.com/repo.git',
|
||||||
|
role: 'upstream',
|
||||||
|
compareRef: 'origin/master',
|
||||||
|
ahead: 1,
|
||||||
|
behind: 2,
|
||||||
|
incoming: [{ sha: 'abc', subject: 'hi' }],
|
||||||
|
};
|
||||||
|
const out = redactRemoteStatus(status);
|
||||||
|
expect(out.url).toBe('https://***:***@example.com/repo.git');
|
||||||
|
// Everything else passes through untouched.
|
||||||
|
expect(out.name).toBe('origin');
|
||||||
|
expect(out.compareRef).toBe('origin/master');
|
||||||
|
expect(out.ahead).toBe(1);
|
||||||
|
expect(out.behind).toBe(2);
|
||||||
|
expect(out.incoming).toEqual([{ sha: 'abc', subject: 'hi' }]);
|
||||||
|
expect(out.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redactRemoteStatus masks credentials echoed into the error string by git stderr', () => {
|
||||||
|
const status: RepoRemoteStatus = {
|
||||||
|
name: 'origin',
|
||||||
|
url: 'https://alice:s3cret@example.com/repo.git',
|
||||||
|
role: 'upstream',
|
||||||
|
compareRef: '',
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
incoming: [],
|
||||||
|
error: "Could not reach origin: fatal: unable to access 'https://alice:s3cret@example.com/repo.git/'",
|
||||||
|
};
|
||||||
|
const out = redactRemoteStatus(status);
|
||||||
|
expect(out.error).toBe("Could not reach origin: fatal: unable to access 'https://***:***@example.com/repo.git/'");
|
||||||
|
expect(out.error).not.toContain('s3cret');
|
||||||
|
expect(out.url).not.toContain('s3cret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSingleFlightCache', () => {
|
||||||
|
it('shares one in-flight computation across concurrent callers', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
let release!: (v: string) => void;
|
||||||
|
const cache = createSingleFlightCache(60_000, () => {
|
||||||
|
calls++;
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const a = cache.get();
|
||||||
|
const b = cache.get();
|
||||||
|
release('result');
|
||||||
|
expect(await a).toBe('result');
|
||||||
|
expect(await b).toBe('result');
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a fresh-enough cached result without recomputing', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const cache = createSingleFlightCache(60_000, async () => ++calls);
|
||||||
|
expect(await cache.get()).toBe(1);
|
||||||
|
expect(await cache.get()).toBe(1);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes once the TTL has elapsed', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const cache = createSingleFlightCache(60_000, async () => ++calls);
|
||||||
|
expect(await cache.get()).toBe(1);
|
||||||
|
// Inject a "now" past the TTL instead of sleeping.
|
||||||
|
expect(await cache.get(Date.now() + 60_001)).toBe(2);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache a rejected computation — the next call retries', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const cache = createSingleFlightCache(60_000, async () => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) throw new Error('boom');
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
await expect(cache.get()).rejects.toThrow('boom');
|
||||||
|
expect(await cache.get()).toBe('ok');
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
getLastTranscriptResponse,
|
||||||
|
isExternalCliTranscriptMode,
|
||||||
|
parseExternalCliTranscript,
|
||||||
|
} from '../src/web/response-viewer-transcript.js';
|
||||||
|
import { isExternalCliMode } from '../src/session.js';
|
||||||
|
import type { SessionMode } from '../src/types.js';
|
||||||
|
|
||||||
|
describe('response viewer transcript parser', () => {
|
||||||
|
it('extracts structured COD transcript blocks and keeps labeled status/tool entries', () => {
|
||||||
|
const transcript = `
|
||||||
|
╭──────────────────────────────────────────────────────╮
|
||||||
|
│ >_ OpenAI Codex (v0.143.0) │
|
||||||
|
│ model: gpt-5.4 medium /model to change │
|
||||||
|
│ directory: /mnt/c/Users/aakhter/.../kb │
|
||||||
|
╰──────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
Tip: Use /side to start a side conversation in a temporary fork without polluting the main thread.
|
||||||
|
|
||||||
|
› i need to create a naming recommendation for project helix
|
||||||
|
|
||||||
|
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ Read SKILL.md
|
||||||
|
|
||||||
|
Subject: Naming Recommendation for Project Helix
|
||||||
|
|
||||||
|
The product helps customers move virtual machine workloads between hypervisors while keeping operations
|
||||||
|
stable. It improves visibility into application dependencies, supports pre-migration validation, and reduces
|
||||||
|
the risk
|
||||||
|
and effort involved in moving workloads.
|
||||||
|
|
||||||
|
› Improve documentation in @filename
|
||||||
|
|
||||||
|
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
|
||||||
|
expect(blocks.map((block) => block.kind)).toEqual([
|
||||||
|
'status',
|
||||||
|
'prompt',
|
||||||
|
'status',
|
||||||
|
'tool',
|
||||||
|
'response',
|
||||||
|
'prompt',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
expect(blocks[0]?.label).toBe('Status');
|
||||||
|
expect(blocks[3]?.label).toBe('Tool');
|
||||||
|
expect(blocks[4]?.text).toContain('reduces the risk and effort involved');
|
||||||
|
expect(blocks[4]?.text).not.toContain('reduces\nthe risk');
|
||||||
|
|
||||||
|
// The frontend's loadFullContext() renders via msg.role: 'user' gets the
|
||||||
|
// "You" badge, everything else the agent badge. A block without role
|
||||||
|
// rendered every prompt as the agent.
|
||||||
|
expect(blocks[1]?.role).toBe('user');
|
||||||
|
expect(blocks[5]?.role).toBe('user');
|
||||||
|
expect(blocks[0]?.role).toBe('assistant');
|
||||||
|
expect(blocks[3]?.role).toBe('assistant');
|
||||||
|
expect(blocks[4]?.role).toBe('assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the most recent completed response when the last block is a new prompt', () => {
|
||||||
|
const transcript = `
|
||||||
|
› say again
|
||||||
|
|
||||||
|
Final polished answer
|
||||||
|
With two lines
|
||||||
|
|
||||||
|
› Improve documentation in @filename
|
||||||
|
|
||||||
|
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer\nWith two lines');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops box-drawing dividers from the last response and treats worked-for lines as status', () => {
|
||||||
|
const transcript = `
|
||||||
|
────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
• The launcher supports CODEMAN_APP_DIR, so I can deploy this exact worktree without merging it back first.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
• Deployed.
|
||||||
|
|
||||||
|
The local Codeman service is now running from the worktree at app/.worktrees/cod-215-response-viewer on
|
||||||
|
commit 8bbcf77bafbdbf01a34653386c256b685898ad06.
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
|
||||||
|
- process pid: 2708947
|
||||||
|
- HTTPS health: https://127.0.0.1:3000/ returned 401 as expected
|
||||||
|
|
||||||
|
One detail: I had to restart it outside the sandbox because the sandboxed launch path could not see tmux.
|
||||||
|
|
||||||
|
─ Worked for 1m 51s ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
› what are these lines in the middle column?
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toContain('• Deployed.');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).not.toContain('────────────────');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).not.toContain('Worked for 1m 51s');
|
||||||
|
expect(blocks.some((block) => block.kind === 'status' && block.text === 'Worked for 1m 51s')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// COD-226: multiline prompt continuations (2-space Codex gutter) must stay in the
|
||||||
|
// Prompt block, not be misclassified as Response. The gutter is authoritative ahead
|
||||||
|
// of every structural detector (divider, prompt-marker, tool, status).
|
||||||
|
describe('COD-226 multiline prompt gutter is authoritative', () => {
|
||||||
|
it('keeps bullet/prose continuations and internal blank lines in the Prompt block', () => {
|
||||||
|
const transcript = `
|
||||||
|
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||||
|
* left hand side: current AI stack (frontier model, cloud hosted, sovereign concerns)
|
||||||
|
right hand -> future enterprise stack: frontier model (optional, cloud), on-prem model router, OSS models
|
||||||
|
|
||||||
|
make the point that the right hand side addresses the concerns.
|
||||||
|
|
||||||
|
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ Read SKILL.md
|
||||||
|
|
||||||
|
Here is the actual assistant answer that starts at column zero and is a real response.
|
||||||
|
|
||||||
|
› next prompt
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||||
|
|
||||||
|
// Exactly two prompts, and the first holds the whole multiline prompt.
|
||||||
|
expect(promptBlocks).toHaveLength(2);
|
||||||
|
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('right hand');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side');
|
||||||
|
|
||||||
|
// The continuation must NOT have leaked into a Response block.
|
||||||
|
const responseBlocks = blocks.filter((b) => b.kind === 'response');
|
||||||
|
expect(responseBlocks.some((b) => b.text.includes('make the point'))).toBe(false);
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toContain('actual assistant answer');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).not.toContain('make the point');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an indented divider inside a prompt flush the Prompt block', () => {
|
||||||
|
const transcript = `
|
||||||
|
› compare these two layouts
|
||||||
|
first layout uses a single column
|
||||||
|
────────────────────────────
|
||||||
|
second layout uses two columns
|
||||||
|
|
||||||
|
The response begins here at column zero.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||||
|
|
||||||
|
expect(promptBlocks).toHaveLength(1);
|
||||||
|
expect(promptBlocks[0]?.text).toContain('first layout');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('second layout uses two columns');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toBe('The response begins here at column zero.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a gutter-indented literal › as prompt content, not a new prompt', () => {
|
||||||
|
const transcript = `
|
||||||
|
› here is my question about the ui
|
||||||
|
› should this arrow start a new prompt?
|
||||||
|
no it should not — it is part of my question
|
||||||
|
|
||||||
|
Answer at column zero.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||||
|
|
||||||
|
// The gutter-indented › must NOT open a second prompt.
|
||||||
|
expect(promptBlocks).toHaveLength(1);
|
||||||
|
expect(promptBlocks[0]?.text).toContain('here is my question');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('no it should not');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toBe('Answer at column zero.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The two exact live roadmap-tab examples from the ticket (AC: use both verbatim).
|
||||||
|
it('live example 1: two-line prompt keeps the gutter continuation in the Prompt block', () => {
|
||||||
|
const transcript = `
|
||||||
|
› create uid for each feature so it's easy to ref.
|
||||||
|
for the p200 - why is diffentiation only 2/5?
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||||
|
|
||||||
|
expect(promptBlocks).toHaveLength(1);
|
||||||
|
expect(promptBlocks[0]?.text).toContain('create uid for each feature');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('for the p200 - why is diffentiation only 2/5?');
|
||||||
|
// The continuation must not have leaked into a Response block.
|
||||||
|
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('live example 2: five-line prompt (bullet + prose + blank + prose) stays one Prompt block', () => {
|
||||||
|
const transcript = `
|
||||||
|
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||||
|
* left hand side... current AI stack: (frontier model), large component cloud hosted, soverign concerns etc.
|
||||||
|
right hand -> future-enterprise-stack: frontier-model (optional, in cloud), on-prem: model router, OSS models, rest of stack (gpus, data, etc.)
|
||||||
|
|
||||||
|
make the point that the right hand side addresses the concerns.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||||
|
|
||||||
|
expect(promptBlocks).toHaveLength(1);
|
||||||
|
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('right hand -> future-enterprise-stack');
|
||||||
|
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side addresses the concerns');
|
||||||
|
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still separates a single-line prompt from a column-zero response (no regression)', () => {
|
||||||
|
const transcript = `
|
||||||
|
› say again
|
||||||
|
|
||||||
|
Final polished answer at column zero.
|
||||||
|
|
||||||
|
› next
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||||
|
|
||||||
|
expect(promptBlocks).toHaveLength(2);
|
||||||
|
expect(promptBlocks[0]?.text).toBe('say again');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer at column zero.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// COD-227: Last Response must return the final assistant answer, not tool logs.
|
||||||
|
// A response bullet beginning with a tool-like verb (• Created …) must not be
|
||||||
|
// classified as Tool, and genuine • Calling / • Called blocks must be classified
|
||||||
|
// as Tool. Disambiguator: a verb-bullet is a tool header only when followed by a
|
||||||
|
// box-drawing result tree (└│├); Calling/Called are always tool markers.
|
||||||
|
describe('COD-227 tool-header vs response disambiguation', () => {
|
||||||
|
const MINIMAL_REPRO = `
|
||||||
|
› new jira issue
|
||||||
|
|
||||||
|
• The fresh read shows a formatting problem.
|
||||||
|
|
||||||
|
• Calling
|
||||||
|
└ atlassian.jira_update_issue({})
|
||||||
|
|
||||||
|
• Called atlassian.jira_get_issue({})
|
||||||
|
└ { result: true }
|
||||||
|
|
||||||
|
• Created COD-226: View Response → More misclassifies multiline prompt continuations as responses.
|
||||||
|
|
||||||
|
It includes:
|
||||||
|
|
||||||
|
- Two concrete failures
|
||||||
|
- Regression-test criteria
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
it('returns the final • Created … answer, not the Jira Calling/Called tool log', () => {
|
||||||
|
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||||
|
const last = getLastTranscriptResponse(blocks);
|
||||||
|
|
||||||
|
expect(last).toContain('Created COD-226');
|
||||||
|
expect(last).toContain('Two concrete failures');
|
||||||
|
// Must exclude tool invocations, raw results, and earlier commentary.
|
||||||
|
expect(last).not.toContain('atlassian.jira');
|
||||||
|
expect(last).not.toContain('Calling');
|
||||||
|
expect(last).not.toContain('Called');
|
||||||
|
expect(last).not.toContain('fresh read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies genuine • Calling / • Called (with box-drawing results) as Tool', () => {
|
||||||
|
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||||
|
const toolText = blocks
|
||||||
|
.filter((b) => b.kind === 'tool')
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
expect(toolText).toContain('Calling');
|
||||||
|
expect(toolText).toContain('Called');
|
||||||
|
expect(toolText).toContain('atlassian.jira_update_issue');
|
||||||
|
// The final answer must not have been swallowed into the tool block.
|
||||||
|
expect(toolText).not.toContain('Created COD-226');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels the repro chronologically: Prompt, Response (commentary), Tool, Response (final)', () => {
|
||||||
|
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||||
|
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['prompt', 'response', 'tool', 'response']);
|
||||||
|
expect(blocks[1]?.text).toContain('fresh read');
|
||||||
|
expect(blocks[3]?.text).toContain('Created COD-226');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not classify a verb-prefixed prose bullet as Tool when no result tree follows', () => {
|
||||||
|
const transcript = `
|
||||||
|
› do it
|
||||||
|
|
||||||
|
• Created COD-999: a brand new issue with a descriptive title.
|
||||||
|
|
||||||
|
Follow-up prose that belongs to the same answer.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
|
||||||
|
expect(blocks.some((b) => b.kind === 'tool')).toBe(false);
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toContain('Created COD-999');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toContain('Follow-up prose');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not regress a genuine verb tool block that has a box-drawing continuation', () => {
|
||||||
|
const transcript = `
|
||||||
|
› look around
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ Read SKILL.md
|
||||||
|
|
||||||
|
Here is the assistant answer at column zero.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||||
|
|
||||||
|
expect(blocks.some((b) => b.kind === 'tool' && b.text.includes('Explored'))).toBe(true);
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toBe('Here is the assistant answer at column zero.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The divider-status detector used to be /^[─-]+\s*(.+?)\s*[─-]{3,}$/, whose
|
||||||
|
// lazy middle backtracked catastrophically on a long dash run that does not
|
||||||
|
// end in 3+ dashes (measured >2min at 8,000 chars). Pane text is
|
||||||
|
// agent-controlled and buffers reach 32MB, so the pattern was replaced by a
|
||||||
|
// linear counter walk. Same approach as the glob-matcher ReDoS fix (68ae9a8):
|
||||||
|
// the hostile input below fails by timeout with the RegExp version.
|
||||||
|
describe('divider-status ReDoS hardening', () => {
|
||||||
|
it('classifies a hostile 10k dash run in linear time', () => {
|
||||||
|
const hostile = '-'.repeat(10_000) + '>';
|
||||||
|
const started = Date.now();
|
||||||
|
const blocks = parseExternalCliTranscript(`› q\n${hostile}`, 'opencode');
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
|
||||||
|
expect(elapsed).toBeLessThan(2_000);
|
||||||
|
// No 3-dash tail, so the line is not a status divider — it stays prose.
|
||||||
|
expect(blocks.some((b) => b.kind === 'response' && b.text.includes('>'))).toBe(true);
|
||||||
|
expect(blocks.some((b) => b.kind === 'status')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The counter walk must keep the EXACT accept set and captured content of
|
||||||
|
// the old regex (including its backtracking quirks on all-dash lines), so
|
||||||
|
// brute-force both against a corpus. The old regex is safe here: probes are
|
||||||
|
// capped at 24 chars, far below the blowup threshold.
|
||||||
|
it('matches the old regex char-for-char on a brute-force corpus', () => {
|
||||||
|
const oldNormalize = (line: string): string | null => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const matched = trimmed.match(/^[─-]+\s*(.+?)\s*[─-]{3,}$/);
|
||||||
|
if (!matched) return null;
|
||||||
|
return matched[1]?.trim() || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Observe the private normalizeDividerStatusLine through the parser: after
|
||||||
|
// a prompt, a column-zero probe from this alphabet is a status block iff
|
||||||
|
// the divider detector matched, and the status text is its return value.
|
||||||
|
// (The alphabet triggers no other status arm; letters are limited to 'x'.)
|
||||||
|
const observedStatusText = (probe: string): string | null => {
|
||||||
|
const blocks = parseExternalCliTranscript(`› q\n${probe}`, 'opencode');
|
||||||
|
const status = blocks.find((b) => b.kind === 'status');
|
||||||
|
return status ? status.text : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const directed = [
|
||||||
|
'──── Status ────',
|
||||||
|
'─ Worked for 1m 51s ───────',
|
||||||
|
'---',
|
||||||
|
'----',
|
||||||
|
'-----',
|
||||||
|
'------',
|
||||||
|
'-------',
|
||||||
|
'---- ---',
|
||||||
|
'- ---',
|
||||||
|
'--x',
|
||||||
|
'x---',
|
||||||
|
'----x',
|
||||||
|
'----x--',
|
||||||
|
'----x---',
|
||||||
|
'─x───',
|
||||||
|
'- x ---',
|
||||||
|
'--- x -',
|
||||||
|
'─── ──',
|
||||||
|
'-\tx\t---',
|
||||||
|
'--->----',
|
||||||
|
'─-─- x x ─-─',
|
||||||
|
'- x x x ----',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Deterministic LCG so a failure reproduces byte-identically.
|
||||||
|
let seed = 0x2f6e2b1;
|
||||||
|
const rand = () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return seed / 0x80000000;
|
||||||
|
};
|
||||||
|
const alphabet = ['-', '-', '─', '─', ' ', ' ', '\t', 'x', '>'];
|
||||||
|
const probes = [...directed];
|
||||||
|
for (let i = 0; i < 3000; i += 1) {
|
||||||
|
const len = Math.floor(rand() * 25);
|
||||||
|
let probe = '';
|
||||||
|
for (let j = 0; j < len; j += 1) {
|
||||||
|
probe += alphabet[Math.floor(rand() * alphabet.length)];
|
||||||
|
}
|
||||||
|
probes.push(probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of probes) {
|
||||||
|
const probe = raw.trim();
|
||||||
|
// Divider-only lines (8+ dash/whitespace chars) are consumed by
|
||||||
|
// isDividerOnlyLine before the status detector ever runs, in both the
|
||||||
|
// old and new worlds — the detector is unobservable there.
|
||||||
|
if (!probe || /^[\s─-]{8,}$/.test(probe)) continue;
|
||||||
|
expect(observedStatusText(probe), `probe: ${JSON.stringify(probe)}`).toBe(oldNormalize(probe));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// EXTERNAL_CLI_MODES is a local duplicate of isExternalCliMode() in
|
||||||
|
// src/session.ts (importing session.ts would drag node-pty and the whole
|
||||||
|
// session layer into the pure transcript module). This pin is what keeps the
|
||||||
|
// two from drifting — 'pi' was missing here while isExternalCliMode() had it,
|
||||||
|
// which left pi sessions with the exact empty-viewer symptom the transcript
|
||||||
|
// branch exists to fix.
|
||||||
|
describe('mode parity with isExternalCliMode()', () => {
|
||||||
|
// Exhaustive by construction: adding a SessionMode without deciding its
|
||||||
|
// transcript behavior fails to compile here.
|
||||||
|
const ALL_SESSION_MODES: Record<SessionMode, true> = {
|
||||||
|
claude: true,
|
||||||
|
shell: true,
|
||||||
|
opencode: true,
|
||||||
|
codex: true,
|
||||||
|
gemini: true,
|
||||||
|
antigravity: true,
|
||||||
|
pi: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('agrees with isExternalCliMode() for every SessionMode', () => {
|
||||||
|
for (const mode of Object.keys(ALL_SESSION_MODES) as SessionMode[]) {
|
||||||
|
expect(isExternalCliTranscriptMode(mode), `mode: ${mode}`).toBe(isExternalCliMode(mode));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers pi', () => {
|
||||||
|
expect(isExternalCliTranscriptMode('pi')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the external-CLI branch of GET /api/sessions/:id/last-response.
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real HTTP ports needed.
|
||||||
|
* Port: N/A (app.inject doesn't open ports)
|
||||||
|
*
|
||||||
|
* OpenCode / Gemini / Antigravity / Pi render their own TUIs and never write a
|
||||||
|
* Claude transcript under ~/.claude/projects, so before this branch existed the
|
||||||
|
* handler fell through to the Claude scan, found nothing, and the response viewer
|
||||||
|
* was permanently empty for those modes. These tests pin:
|
||||||
|
* - the pane buffer is segmented and the LAST response is returned
|
||||||
|
* - ?context=full carries the parsed blocks, and the short form omits them
|
||||||
|
* - ?context=full blocks carry role — the frontend renders via msg.role, so a
|
||||||
|
* block without it lost the "You" badge on prompts
|
||||||
|
* - a pane that has produced no output reports hasContext: false rather than 404ing
|
||||||
|
* - Claude mode still takes the Claude path (regression guard)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||||
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
|
interface LocalHarness {
|
||||||
|
app: FastifyInstance;
|
||||||
|
ctx: MockRouteContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of the production uniform-envelope hook (server.ts), as in the sibling suites. */
|
||||||
|
async function createEnvelopeHarness(
|
||||||
|
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
|
||||||
|
): Promise<LocalHarness> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
|
||||||
|
const ctx = createMockRouteContext();
|
||||||
|
registerFn(app, ctx);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.ready();
|
||||||
|
return { app, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pane as one of these CLIs actually leaves it: banner, a `›` prompt line, a
|
||||||
|
// status divider, a tool-activity marker, then the assistant's prose.
|
||||||
|
const PANE = `
|
||||||
|
╭──────────────────────────────────────────────────────╮
|
||||||
|
│ >_ OpenCode │
|
||||||
|
│ directory: /workspace/project │
|
||||||
|
╰──────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
› summarise the retry logic
|
||||||
|
|
||||||
|
model · project · main · Ready · Context 100% left
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ Read src/retry.ts
|
||||||
|
|
||||||
|
The retry helper backs off exponentially and gives up after five attempts.
|
||||||
|
|
||||||
|
› now document it
|
||||||
|
|
||||||
|
model · project · main · Ready · Context 92% left
|
||||||
|
|
||||||
|
• Called write_file
|
||||||
|
|
||||||
|
Documented the helper in docs/retry.md, including the five-attempt ceiling.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
|
||||||
|
let harness: LocalHarness;
|
||||||
|
let session: ReturnType<typeof createMockSession>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||||
|
session = harness.ctx._session;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function lastResponse(full = false) {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${session.id}/last-response${full ? '?context=full' : ''}`,
|
||||||
|
});
|
||||||
|
return { res, body: JSON.parse(res.body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
|
||||||
|
it(`returns the last assistant response from the ${mode} pane buffer`, async () => {
|
||||||
|
session.mode = mode;
|
||||||
|
session.terminalBuffer = PANE;
|
||||||
|
|
||||||
|
const { res, body } = await lastResponse();
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
// The LAST response, not the first — the viewer shows the current turn.
|
||||||
|
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
|
||||||
|
expect(body.data.text).not.toContain('backs off exponentially');
|
||||||
|
expect(body.data.hasContext).toBe(true);
|
||||||
|
// Short form stays short: blocks only travel under ?context=full.
|
||||||
|
expect(body.data.messages).toBeUndefined();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('carries the parsed blocks under ?context=full', async () => {
|
||||||
|
session.mode = 'opencode';
|
||||||
|
session.terminalBuffer = PANE;
|
||||||
|
|
||||||
|
const { body } = await lastResponse(true);
|
||||||
|
|
||||||
|
const kinds = body.data.messages.map((block: { kind: string }) => block.kind);
|
||||||
|
expect(kinds).toContain('prompt');
|
||||||
|
expect(kinds).toContain('response');
|
||||||
|
expect(kinds).toContain('tool');
|
||||||
|
// Both user turns survive segmentation, so the viewer can show the exchange.
|
||||||
|
const prompts = body.data.messages.filter((b: { kind: string }) => b.kind === 'prompt');
|
||||||
|
expect(prompts).toHaveLength(2);
|
||||||
|
expect(prompts[1].text).toContain('now document it');
|
||||||
|
|
||||||
|
// loadFullContext() renders via msg.role — without it every block got the
|
||||||
|
// agent badge and the user's own prompts lost their "You" attribution.
|
||||||
|
for (const block of body.data.messages as Array<{ kind: string; role: string }>) {
|
||||||
|
expect(block.role).toBe(block.kind === 'prompt' ? 'user' : 'assistant');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports hasContext false for a pane that has produced no output', async () => {
|
||||||
|
session.mode = 'gemini';
|
||||||
|
// Session created but nothing rendered yet. Any non-prompt line counts as
|
||||||
|
// prose to the parser, so the empty pane is the honest no-context case.
|
||||||
|
session.terminalBuffer = '';
|
||||||
|
|
||||||
|
const { res, body } = await lastResponse();
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(body.data.text).toBe('');
|
||||||
|
expect(body.data.hasContext).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Claude mode on the Claude transcript path', async () => {
|
||||||
|
// Regression guard: a claude pane must NOT be segmented off its terminal
|
||||||
|
// buffer, or a real transcript would be shadowed by scraped pane text.
|
||||||
|
session.mode = 'claude';
|
||||||
|
session.terminalBuffer = PANE;
|
||||||
|
|
||||||
|
const { res, body } = await lastResponse();
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(body.data.text).not.toContain('Documented the helper');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -789,6 +789,7 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer).toContain(lastLine);
|
expect(body.data.terminalBuffer).toContain(lastLine);
|
||||||
expect(body.data.source).toBe('mux-full-history');
|
expect(body.data.source).toBe('mux-full-history');
|
||||||
expect(typeof body.data.fullSize).toBe('number');
|
expect(typeof body.data.fullSize).toBe('number');
|
||||||
|
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* emitted through onData (the bytes that would reach the PTY).
|
* emitted through onData (the bytes that would reach the PTY).
|
||||||
*
|
*
|
||||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||||
* Playwright suites. Run locally: npm test -- test/terminal-copy-shortcut.test.ts
|
* Playwright suites. Run locally: npm run test:browser -- test/terminal-copy-shortcut.test.ts
|
||||||
*
|
*
|
||||||
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
|
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +23,7 @@ import { WebServer } from '../src/web/server.js';
|
|||||||
|
|
||||||
const PORT = 3174;
|
const PORT = 3174;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
const IME_PUNCTUATION = ',。!?;:“”、《》、()';
|
||||||
|
|
||||||
describe('terminal Ctrl+C smart copy', () => {
|
describe('terminal Ctrl+C smart copy', () => {
|
||||||
let server: WebServer;
|
let server: WebServer;
|
||||||
@@ -102,6 +103,59 @@ describe('terminal Ctrl+C smart copy', () => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function captureImeInput(targetPage: Page) {
|
||||||
|
await targetPage.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||||
|
await targetPage.evaluate(() => {
|
||||||
|
const term = (window as any).app.terminal;
|
||||||
|
(window as any).__data = [];
|
||||||
|
if (!(window as any).__dataHooked) {
|
||||||
|
term.onData((d: string) => (window as any).__data.push(d));
|
||||||
|
(window as any).__dataHooked = true;
|
||||||
|
}
|
||||||
|
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||||
|
await cdp.send('Input.imeSetComposition', { text: '中文', selectionStart: 2, selectionEnd: 2 });
|
||||||
|
await cdp.send('Input.insertText', { text: '中文' });
|
||||||
|
await targetPage.waitForFunction(() => (window as any).__data.join('') === '中文', null, { polling: 10 });
|
||||||
|
|
||||||
|
let expected = '中文';
|
||||||
|
for (const punctuation of Array.from(IME_PUNCTUATION)) {
|
||||||
|
// Keep keydown -> DOM mutation -> input in one browser task, as a
|
||||||
|
// native key default action does. Separate CDP calls can let xterm's
|
||||||
|
// zero-delay textarea diff run before Input.insertText reaches the page.
|
||||||
|
await targetPage.evaluate((text) => {
|
||||||
|
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||||
|
const down = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Process',
|
||||||
|
code: 'Unidentified',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
});
|
||||||
|
Object.defineProperties(down, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||||
|
textarea.dispatchEvent(down);
|
||||||
|
if (!document.execCommand('insertText', false, text)) throw new Error('browser rejected insertText');
|
||||||
|
const up = new KeyboardEvent('keyup', {
|
||||||
|
key: 'Process',
|
||||||
|
code: 'Unidentified',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
});
|
||||||
|
Object.defineProperties(up, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||||
|
textarea.dispatchEvent(up);
|
||||||
|
}, punctuation);
|
||||||
|
expected += punctuation;
|
||||||
|
await targetPage.waitForFunction((text) => (window as any).__data.join('') === text, expected, {
|
||||||
|
polling: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetPage.evaluate(() => (window as any).__data as string[]);
|
||||||
|
}
|
||||||
|
|
||||||
it('copies the selection and sends nothing to the PTY', async () => {
|
it('copies the selection and sends nothing to the PTY', async () => {
|
||||||
await setup('COPY-CASE-SELECTED', true);
|
await setup('COPY-CASE-SELECTED', true);
|
||||||
await page.keyboard.press('Control+c');
|
await page.keyboard.press('Control+c');
|
||||||
@@ -163,4 +217,20 @@ describe('terminal Ctrl+C smart copy', () => {
|
|||||||
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
|
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
|
||||||
expect(res.data.join('')).not.toContain('\x16');
|
expect(res.data.join('')).not.toContain('\x16');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards full-width punctuation after a Chinese IME composition', async () => {
|
||||||
|
await setup('IME-PUNCTUATION', false);
|
||||||
|
const desktopChunks = await captureImeInput(page);
|
||||||
|
expect(desktopChunks.join('')).toBe('中文' + IME_PUNCTUATION);
|
||||||
|
|
||||||
|
const touchContext = await browser.newContext({ hasTouch: true });
|
||||||
|
try {
|
||||||
|
const touchPage = await touchContext.newPage();
|
||||||
|
await touchPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||||
|
const touchChunks = await captureImeInput(touchPage);
|
||||||
|
expect(touchChunks.join('')).toBe('中文' + IME_PUNCTUATION);
|
||||||
|
} finally {
|
||||||
|
await touchContext.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('terminal flush budget', () => {
|
|||||||
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
|
it('releases the live-output gate but waits for xterm to parse a small replay', async () => {
|
||||||
const { app, writes } = loadTerminalUiHarness('codex');
|
const { app, writes } = loadTerminalUiHarness('codex');
|
||||||
let writeDone: (() => void) | undefined;
|
let writeDone: (() => void) | undefined;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -109,13 +109,90 @@ describe('terminal flush budget', () => {
|
|||||||
expect(writes).toEqual(['fresh tmux pane frame']);
|
expect(writes).toEqual(['fresh tmux pane frame']);
|
||||||
expect(writeDone).toBeTypeOf('function');
|
expect(writeDone).toBeTypeOf('function');
|
||||||
expect(resolved).toBe(false);
|
expect(resolved).toBe(false);
|
||||||
expect(finishBufferLoad).not.toHaveBeenCalled();
|
expect(finishBufferLoad).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
writeDone?.();
|
writeDone?.();
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(resolved).toBe(true);
|
expect(resolved).toBe(true);
|
||||||
expect(finishBufferLoad).toHaveBeenCalledOnce();
|
});
|
||||||
|
|
||||||
|
it('paces a large enqueue and releases live output before the parse marker completes', async () => {
|
||||||
|
const { app, writes } = loadTerminalUiHarness('shell');
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
let parseDone: (() => void) | undefined;
|
||||||
|
let resolved = false;
|
||||||
|
let result: { parsedAt: number; bufferLength: number; completed: boolean } | undefined;
|
||||||
|
app._safeYield = (callback: () => void) => scheduled.push(callback);
|
||||||
|
app.isTerminalAtBottom = () => true;
|
||||||
|
app.terminal.buffer = { active: { length: 37 } };
|
||||||
|
app.terminal.write = (data: string, callback?: () => void) => {
|
||||||
|
writes.push(data);
|
||||||
|
if (callback) parseDone = callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = app.chunkedTerminalWrite('x'.repeat(3 * 32 * 1024)).then((value: typeof result) => {
|
||||||
|
result = value;
|
||||||
|
resolved = true;
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
expect(scheduled).toHaveLength(1);
|
||||||
|
|
||||||
|
scheduled.shift()?.();
|
||||||
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024]);
|
||||||
|
expect(scheduled).toHaveLength(1);
|
||||||
|
|
||||||
|
scheduled.shift()?.();
|
||||||
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
|
||||||
|
scheduled.shift()?.();
|
||||||
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 0]);
|
||||||
|
expect(app._isLoadingBuffer).toBe(false);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
|
||||||
|
app.batchTerminalWrite('new output after snapshot');
|
||||||
|
expect(app._loadBufferQueue).toBe(null);
|
||||||
|
expect(app.pendingWrites).toEqual(['new output after snapshot']);
|
||||||
|
|
||||||
|
parseDone?.();
|
||||||
|
app.terminal.buffer.active.length = 42;
|
||||||
|
await promise;
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
expect(result?.bufferLength).toBe(37);
|
||||||
|
expect(result?.completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a parse callback stale when a newer replay supersedes it', async () => {
|
||||||
|
const { app } = loadTerminalUiHarness('shell');
|
||||||
|
let parseDone: (() => void) | undefined;
|
||||||
|
app.terminal.write = (_data: string, callback?: () => void) => {
|
||||||
|
parseDone = callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstReplay = app.chunkedTerminalWrite('old snapshot');
|
||||||
|
app._chunkedWriteGen += 1;
|
||||||
|
parseDone?.();
|
||||||
|
|
||||||
|
await expect(firstReplay).resolves.toMatchObject({ completed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scans xterm rows from buffer.length instead of double-counting baseY', () => {
|
||||||
|
const { app } = loadTerminalUiHarness('shell');
|
||||||
|
const getLine = vi.fn((index: number) =>
|
||||||
|
index === 99 || index === 77 ? { translateToString: () => 'content' } : undefined
|
||||||
|
);
|
||||||
|
app.terminal = {
|
||||||
|
rows: 24,
|
||||||
|
buffer: { active: { baseY: 76, length: 100, getLine } },
|
||||||
|
scrollToBottom: vi.fn(),
|
||||||
|
scrollToLine: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.scrollToLastNonEmptyLine();
|
||||||
|
|
||||||
|
expect(getLine.mock.calls[0]?.[0]).toBe(99);
|
||||||
|
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(77);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps stale buffer load owners from finishing a newer load', () => {
|
it('keeps stale buffer load owners from finishing a newer load', () => {
|
||||||
|
|||||||
@@ -675,6 +675,7 @@ describe('TmuxManager (unit)', () => {
|
|||||||
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
|
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
|
||||||
workingDir: '/mnt/gdrive/project with spaces',
|
workingDir: '/mnt/gdrive/project with spaces',
|
||||||
mode: 'shell',
|
mode: 'shell',
|
||||||
|
historyLimit: 250_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(session.workingDir).toBe('/mnt/gdrive/project with spaces');
|
expect(session.workingDir).toBe('/mnt/gdrive/project with spaces');
|
||||||
@@ -683,7 +684,9 @@ describe('TmuxManager (unit)', () => {
|
|||||||
const newSessionCall = mockedExecSync.mock.calls.find(
|
const newSessionCall = mockedExecSync.mock.calls.find(
|
||||||
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
|
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
|
||||||
);
|
);
|
||||||
expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`);
|
expect(newSessionCall?.[0]).toBe(
|
||||||
|
`tmux -L 'codeman' set-option -g history-limit 250000 \\; new-session -ds "codeman-abc12345" -c /tmp \\; set-option -t "codeman-abc12345" history-limit 250000`
|
||||||
|
);
|
||||||
expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' }));
|
expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' }));
|
||||||
|
|
||||||
const respawnCall = mockedExecSync.mock.calls.find(
|
const respawnCall = mockedExecSync.mock.calls.find(
|
||||||
@@ -696,6 +699,59 @@ describe('TmuxManager (unit)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('changes the global history default on tmux versions that cannot resize panes', async () => {
|
||||||
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nonTestManager.setHistoryLimit(200_000);
|
||||||
|
const historyCall = mockedExec.mock.calls.find(
|
||||||
|
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
|
||||||
|
);
|
||||||
|
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -g history-limit 200000`);
|
||||||
|
expect(historyCall?.[0]).not.toContain(' -t ');
|
||||||
|
} finally {
|
||||||
|
nonTestManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('targets only the new and tracked sessions on tmux 3.7+', async () => {
|
||||||
|
mockedExecSync.mockImplementation((cmd: string) => {
|
||||||
|
if (typeof cmd === 'string' && cmd.endsWith(' -V')) return 'tmux 3.7b\n';
|
||||||
|
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
|
||||||
|
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nonTestManager.createSession({
|
||||||
|
sessionId: 'def67890-1234-5678-90ab-cdef12345678',
|
||||||
|
workingDir: '/project',
|
||||||
|
mode: 'shell',
|
||||||
|
historyLimit: 250_000,
|
||||||
|
});
|
||||||
|
const newSessionCall = mockedExecSync.mock.calls.find(
|
||||||
|
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
|
||||||
|
);
|
||||||
|
expect(newSessionCall?.[0]).toBe(
|
||||||
|
`tmux -L 'codeman' new-session -ds "codeman-def67890" -c /tmp \\; set-option -t "codeman-def67890" history-limit 250000`
|
||||||
|
);
|
||||||
|
expect(newSessionCall?.[0]).not.toContain('set-option -g');
|
||||||
|
|
||||||
|
mockedExec.mockClear();
|
||||||
|
await nonTestManager.setHistoryLimit(200_000);
|
||||||
|
const historyCall = mockedExec.mock.calls.find(
|
||||||
|
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
|
||||||
|
);
|
||||||
|
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -t 'codeman-def67890' history-limit 200000`);
|
||||||
|
expect(historyCall?.[0]).not.toContain('set-option -g');
|
||||||
|
} finally {
|
||||||
|
nonTestManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => {
|
it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => {
|
||||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
const nonTestManager = new NonTestTmuxManager();
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
|||||||
Reference in New Issue
Block a user