mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ae54536cb | ||
|
|
2d2a455dd2 | ||
|
|
943f04ba53 | ||
|
|
69d8a9ea6f | ||
|
|
405eb50ba3 | ||
|
|
b6f15b30c6 | ||
|
|
736f35da7f | ||
|
|
6866a617a8 | ||
|
|
a415948736 | ||
|
|
d68cba9432 | ||
|
|
9a0e665f72 | ||
|
|
4bbe2b7ff6 | ||
|
|
a7928f5c64 | ||
|
|
4fa44f2e55 | ||
|
|
829b202f51 | ||
|
|
c790166564 | ||
|
|
d19895651d |
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
"aicodeman": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Add Pi (pi.dev) as a sixth CLI run mode (#206).
|
|
||||||
|
|
||||||
`SessionMode` gains `'pi'`, a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron `agentType`, Docker and remote-SSH command defaults, and clone-repo Brain option.
|
|
||||||
|
|
||||||
- **New resolver** `src/utils/pi-cli-resolver.ts`. Unlike the sibling resolvers it sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name that a stray binary on `$PATH` can shadow; the rejected path is logged. `GET /api/pi/status` returns `{ available, path, version }` so a misresolution is diagnosable.
|
|
||||||
- **`PiConfig`** maps to `--model` (accepts `provider/id` and a `:thinking` suffix), `--provider`, `--thinking`, `--session`/`-c`, and the tri-state `--approve` / `--no-approve`. Every value is regex-allowlisted and dropped on failure. `--api-key` is deliberately never wired: it would put a provider secret on the spawn command line.
|
|
||||||
- **No bypass flag.** Pi has no permission prompts and no sandbox, so there is no `--dangerously-skip-permissions` analog. Its privilege-shaped knob is `approveProjectTrust`, which makes pi load and execute repo-local `.pi/extensions` TypeScript and install missing project packages. `clampExternalCliBypassForOwner()` therefore puts pi in the **materialize** branch: a non-granted multi-user owner gets `--no-approve` even when no config was sent, because pi's own default is an interactive prompt the session user could answer themselves. The same materialization applies to cron-fired jobs (`clampCronExternalCliConfigs`), which carry no per-CLI config and would otherwise launch on pi's own default. Both helpers had no test coverage at all; they now do, for every CLI.
|
|
||||||
- **Env allowlist gains only the `PI_*` prefix.** Pi's ~34 provider key vars share no prefix and `ALLOWED_ENV_PREFIXES` is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Users authenticate via pi's `/login` or the server process's own environment.
|
|
||||||
- **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback and is mouse-aware, so it consumes `\x1b[3J` and the mouse DECSETs that the full strip removes, unlike an Ink TUI repainting in place. Note what exclusion does NOT do: pi is tmux-backed, so it still falls through to the narrow `isMuxAltScreenOnlyStripMode()` strip and its alt-screen toggles are dropped either way. Pi's runtime-switchable fullscreen TUI therefore paints into the main buffer, exactly like vim inside a tmux `shell` session.
|
|
||||||
- **Docker**: pi installs in its own `--ignore-scripts` step so that flag cannot affect the other four CLIs, and its credentials are seeded per-file (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json`) rather than whole-dir, since `~/.pi/agent` also holds sessions, extensions and installed package trees.
|
|
||||||
- **Local echo**: pi lands on the buffer overlay. Verified that codex's per-keystroke starvation does not reproduce — pi's slash picker re-filters on the whole composer content, so a one-shot flush behaves identically to per-keystroke typing.
|
|
||||||
- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. The packaged agent skill's mode enumerations list pi too, and it now documents the per-CLI availability probes (`GET /api/<mode>/status`) that agents should check before spawning a worker on a backend the server may not have installed. Both are pinned by a new guard that derives the mode set from the Zod schema instead of restating it.
|
|
||||||
- **`codeman doctor` and the run mode agree about pi.** The registry entry resolved a bare `which pi` while `pi-cli-resolver` demanded semver output, so the Dependencies panel could report an installed Pi CLI that sessions refuse to launch. Both now share one exported regex, and the registry's new `requireVersionMatch` reports a non-semver `pi` as missing rather than installed. Only pi sets it; every other tool keeps its existing behaviour.
|
|
||||||
- Installer detection, docs (`docs/pi-integration.md`), READMEs, and the architecture invariants are updated. Tests: `test/pi-mode.test.ts` and `test/routes/external-cli-bypass-clamp.test.ts`, plus extensions to the run-mode, mobile-overview, render-index-html, system-routes and local-echo suites.
|
|
||||||
@@ -1,5 +1,52 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.18.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Terminal history and scroll position fixes, a seekable file-viewer video player, and clearer session lineage lines.
|
||||||
|
|
||||||
|
**Terminal scroll position (#259).** Three paths dragged the terminal to the bottom while the user was reading scrollback. Opening or closing the mobile keyboard forced it unconditionally; scroll intent is now captured before the keyboard reflow and restored afterwards. Live writes preserved the viewport only inside a 1500ms window, so a user who scrolled up and then actually read for longer was dragged along by the next repaint; that is now based on position rather than recency. The backpressure refresh, which is server-triggered and so has no gesture to blame, now holds the reader's place too.
|
||||||
|
|
||||||
|
**Terminal history loss (#259 follow-on).** The backpressure refresh rebuilt the terminal from a 1MB tail, which measured as an 869-row buffer coming back with 158 rows: the routine meant to repair the display was discarding most of the scrollback every time SSE backpressure cleared. It now restores full history, falling back to the tail only when the capture would shrink the buffer, so repaint-mode panes are unaffected. It also bails if the user switches tabs mid-fetch, which would otherwise paint one session's history into another's terminal.
|
||||||
|
|
||||||
|
**History truncation is now visible and recoverable (#258).** Truncation was reported by a grey line written into the terminal, which scrolled away with the output it described and read the same whether the rest was one click away or gone forever. `GET /api/sessions/:id/terminal` now reports `truncationReason` (`tail` for an intentional partial replay whose remainder is still retained, `capped` for the byte ceiling) plus `retainedBytes`, and the browser shows a dismissible banner outside terminal output with three honest states: recoverable, which offers a Load full history button, at-ceiling, and exhausted. The button bypasses the scroll cooldown but not the downgrade guard, so it cannot destroy history on a repaint-mode pane.
|
||||||
|
|
||||||
|
**File viewer video (#284).** Closing the preview left the video playing with audible audio and no visible player, since hiding the overlay does not stop a media element and detaching one does not either. Media is now paused, unsourced and reloaded on close and on re-open, which also aborts the in-flight download. The scrub bar was inert because raw file bodies were served as a single `200` with no `Accept-Ranges`, so Chrome reported `video.seekable` as `[0, 0]` and Safari refused to start the media at all. Raw bodies are now streamed and range-aware (`Accept-Ranges` on every response, `206` with `Content-Range` for a range request, `416` past EOF, malformed specs ignored per RFC 9110), with pure, unit-tested parsing in `src/web/http-range.ts`. The attachments raw route gets the same treatment.
|
||||||
|
|
||||||
|
**Session lineage lines (#285).** The arcs joining a tab to the workers it spawned were tuned for two adjacent tabs and flattened into a straight thread across the terminal at the 800-1500px spans they are actually used at, drew a flat overprinted line inside the row gap on a wrapped strip, and were too faint to see at 1:1. Every pair now uses one U-bridge shape anchored on both tabs' bottom edges, with a deeper span-scaled dip and heavier, higher-contrast strokes.
|
||||||
|
|
||||||
|
**Docs.** The pi run mode is now listed in the mode lists that the sixth-backend sweep missed.
|
||||||
|
|
||||||
|
## 1.18.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Heal a stalled SSE stream with a heartbeat and a client-side staleness watchdog, and make a tab rename apply immediately.
|
||||||
|
|
||||||
|
An `EventSource` that stops delivering does not always error. A proxy that idle-closed the connection, a laptop resumed from sleep, a tailnet reconnect: `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. Nothing on the client tracked stream liveness at all.
|
||||||
|
- **`sse:heartbeat` is a new named event** under a new Transport category in the registry (155 constants now, both the backend list and the frontend `SSE_EVENTS` copy updated). The server already wrote a keepalive every 15s, but as an SSE `:keepalive` **comment**, and comments are invisible to `EventSource` by spec, so there was nothing a client could observe. `cleanupDeadClients()` now writes the named frame (`{"t":<epoch ms>}`) instead; interval, tunnel padding and dead-socket eviction are unchanged, and the write stays per-client rather than going through `broadcast()` because the frame carries no session data and so needs no multi-user owner routing.
|
||||||
|
- **Client watchdog.** `computeSseStale()` in `constants.js` is a pure policy beside `computeConnectionLossUi`: stale only when the transport believes it is `connected`, the device is online, and no frame has arrived for 45s (three missed heartbeats). That `connected`-only guard doubles as the loop breaker, since a forced reconnect leaves the state immediately and the watchdog cannot re-fire while one is in flight. The liveness stamp is applied inside `addListener` itself so every registered listener feeds it from one place instead of three that can drift, and the heartbeat's own listener is a deliberate no-op that exists only to be registered (`EventSource` drops named events nobody listens for). A 5s watchdog forces `connectSSE()`, `visibilitychange` to visible checks too (a background tab's timers are throttled, and a wake is exactly when a stream comes back zombie), and the forced reconnect logs one diagnostic line so a middlebox that strips or delays heartbeats does not present as an undebuggable "silently reconnects every 45s".
|
||||||
|
- **Renaming a tab appeared to do nothing** until a full page reload. The `PUT` always succeeded; what was broken is how the tab strip learned the result. `finishRename()` re-renders from the client-side `app.sessions` map and nothing wrote the new name into it, so the rename depended on the `session:updated` SSE frame to carry its own write back, which is precisely what a quiet stream never delivers. `_applyLocalSessionName()` now writes the confirmed name locally and refreshes cached subagent parent names. A rejected rename also used to read as success and silently drop the edit, because `_apiPut` turns a network error into a null Response so the old `try`/`catch` could never fire; a failure now restores the old label and toasts.
|
||||||
|
|
||||||
|
Tests: `test/sse-staleness.test.ts` (node VM over `constants.js`, threshold boundaries and every not-stale guard), `test/sse-heartbeat.test.ts` (drives `cleanupDeadClients()` with fake replies: named frame not a comment, parseable payload, padding only with a tunnel, dead clients still evicted), and `test/inline-rename.test.ts` (the name applies with no SSE frame dispatched, and a 500 leaves the map untouched).
|
||||||
|
|
||||||
|
Event names are part of the stable `/api/v1` contract, so this is a minor bump.
|
||||||
|
|
||||||
|
- c5b5963: Add Pi (pi.dev) as a sixth CLI run mode (#206).
|
||||||
|
|
||||||
|
`SessionMode` gains `'pi'`, a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron `agentType`, Docker and remote-SSH command defaults, and clone-repo Brain option.
|
||||||
|
- **New resolver** `src/utils/pi-cli-resolver.ts`. Unlike the sibling resolvers it sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name that a stray binary on `$PATH` can shadow; the rejected path is logged. `GET /api/pi/status` returns `{ available, path, version }` so a misresolution is diagnosable.
|
||||||
|
- **`PiConfig`** maps to `--model` (accepts `provider/id` and a `:thinking` suffix), `--provider`, `--thinking`, `--session`/`-c`, and the tri-state `--approve` / `--no-approve`. Every value is regex-allowlisted and dropped on failure. `--api-key` is deliberately never wired: it would put a provider secret on the spawn command line.
|
||||||
|
- **No bypass flag.** Pi has no permission prompts and no sandbox, so there is no `--dangerously-skip-permissions` analog. Its privilege-shaped knob is `approveProjectTrust`, which makes pi load and execute repo-local `.pi/extensions` TypeScript and install missing project packages. `clampExternalCliBypassForOwner()` therefore puts pi in the **materialize** branch: a non-granted multi-user owner gets `--no-approve` even when no config was sent, because pi's own default is an interactive prompt the session user could answer themselves. The same materialization applies to cron-fired jobs (`clampCronExternalCliConfigs`), which carry no per-CLI config and would otherwise launch on pi's own default. Both helpers had no test coverage at all; they now do, for every CLI.
|
||||||
|
- **Env allowlist gains only the `PI_*` prefix.** Pi's ~34 provider key vars share no prefix and `ALLOWED_ENV_PREFIXES` is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Users authenticate via pi's `/login` or the server process's own environment.
|
||||||
|
- **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback and is mouse-aware, so it consumes `\x1b[3J` and the mouse DECSETs that the full strip removes, unlike an Ink TUI repainting in place. Note what exclusion does NOT do: pi is tmux-backed, so it still falls through to the narrow `isMuxAltScreenOnlyStripMode()` strip and its alt-screen toggles are dropped either way. Pi's runtime-switchable fullscreen TUI therefore paints into the main buffer, exactly like vim inside a tmux `shell` session.
|
||||||
|
- **Docker**: pi installs in its own `--ignore-scripts` step so that flag cannot affect the other four CLIs, and its credentials are seeded per-file (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json`) rather than whole-dir, since `~/.pi/agent` also holds sessions, extensions and installed package trees.
|
||||||
|
- **Local echo**: pi lands on the buffer overlay. Verified that codex's per-keystroke starvation does not reproduce: pi's slash picker re-filters on the whole composer content, so a one-shot flush behaves identically to per-keystroke typing.
|
||||||
|
- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. The packaged agent skill's mode enumerations list pi too, and it now documents the per-CLI availability probes (`GET /api/<mode>/status`) that agents should check before spawning a worker on a backend the server may not have installed. Both are pinned by a new guard that derives the mode set from the Zod schema instead of restating it.
|
||||||
|
- **`codeman doctor` and the run mode agree about pi.** The registry entry resolved a bare `which pi` while `pi-cli-resolver` demanded semver output, so the Dependencies panel could report an installed Pi CLI that sessions refuse to launch. Both now share one exported regex, and the registry's new `requireVersionMatch` reports a non-semver `pi` as missing rather than installed. Only pi sets it; every other tool keeps its existing behaviour.
|
||||||
|
- Installer detection, docs (`docs/pi-integration.md`), READMEs, and the architecture invariants are updated. Tests: `test/pi-mode.test.ts` and `test/routes/external-cli-bypass-clamp.test.ts`, plus extensions to the run-mode, mobile-overview, render-index-html, system-routes and local-echo suites.
|
||||||
|
|
||||||
## 1.17.0
|
## 1.17.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.17.0 (must match `package.json`)
|
**Version**: 1.18.1 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting; the dip is also clamped at 104px rather than 44, since a skill worker lands at the END of the strip where the old cap flattened the arc into a straight thread. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||||
|
|
||||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||||
|
|
||||||
@@ -234,6 +234,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||||
|
|
||||||
|
**Raw file bodies are streamed and range-aware**: `file-raw` and the attachments `/raw` route always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file. ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||||
|
|
||||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||||
|
|
||||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||||
@@ -292,6 +294,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||||
|
|
||||||
|
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), local echo overlay (7).
|
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), local echo overlay (7).
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
@@ -320,7 +324,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### SSE Event Registry
|
### SSE Event Registry
|
||||||
|
|
||||||
154 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 154 = 154, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown.
|
155 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 155 = 155, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown.
|
||||||
|
|
||||||
### API Routes
|
### API Routes
|
||||||
|
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ These run for **every** request — before auth, even on the default no-password
|
|||||||
|
|
||||||
### Input, files & headers
|
### Input, files & headers
|
||||||
|
|
||||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` env-prefix allowlist gates which settings each CLI can receive
|
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` env-prefix allowlist gates which settings each CLI can receive
|
||||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||||
|
|
||||||
@@ -782,7 +782,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
|
|||||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||||
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
||||||
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
|
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
|
||||||
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
||||||
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
|
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
|
||||||
|
|
||||||
### Recipes
|
### Recipes
|
||||||
|
|||||||
+3
-3
@@ -602,7 +602,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
|
|
||||||
### 输入、文件与响应头
|
### 输入、文件与响应头
|
||||||
|
|
||||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||||
|
|
||||||
@@ -686,7 +686,7 @@ sc -l # 列出会话
|
|||||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||||
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
||||||
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
|
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
|
||||||
7. **只有 `claude` 会话会发出 `stop` 与 `blocked`。** 这两个来自 Claude Code hook;`shell` 与外部 CLI(opencode/codex/gemini/antigravity)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
7. **只有 `claude` 会话会发出 `stop` 与 `blocked`。** 这两个来自 Claude Code hook;`shell` 与外部 CLI(opencode/codex/gemini/antigravity/pi)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
||||||
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
|
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
|
||||||
|
|
||||||
### 常用配方
|
### 常用配方
|
||||||
@@ -760,7 +760,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
# 5b. 其他模式(shell/opencode/gemini/antigravity)没有 transcript,读终端。
|
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi)没有 transcript,读终端。
|
||||||
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
||||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||||
|
|||||||
+24
-1
@@ -112,7 +112,7 @@ a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status
|
|||||||
|
|
||||||
**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude
|
**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude
|
||||||
Code hooks, and no other mode installs them: `shell` runs no agent, and the external
|
Code hooks, and no other mode installs them: `shell` runs no agent, and the external
|
||||||
CLIs (`opencode`, `codex`, `gemini`, `antigravity`) render their own TUIs and post
|
CLIs (`opencode`, `codex`, `gemini`, `antigravity`, `pi`) render their own TUIs and post
|
||||||
no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are
|
no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are
|
||||||
accepted, and of those only `exit` is dependable: see the caveats under
|
accepted, and of those only `exit` is dependable: see the caveats under
|
||||||
[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked`
|
[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked`
|
||||||
@@ -534,6 +534,29 @@ the stable contract — event names are not renamed without a major bump. An
|
|||||||
optional `?sessions=<id,...>` filter suppresses only the high-volume terminal
|
optional `?sessions=<id,...>` filter suppresses only the high-volume terminal
|
||||||
stream; lifecycle/metadata events are delivered to all clients regardless.
|
stream; lifecycle/metadata events are delivered to all clients regardless.
|
||||||
|
|
||||||
|
### `sse:heartbeat` (liveness)
|
||||||
|
|
||||||
|
Every 15s the server writes a `sse:heartbeat` frame to every connected client:
|
||||||
|
|
||||||
|
```
|
||||||
|
event: sse:heartbeat
|
||||||
|
data: {"t":1755100000000}
|
||||||
|
```
|
||||||
|
|
||||||
|
`t` is the server's epoch-ms timestamp at write time. The frame carries no
|
||||||
|
application state and can be ignored for correctness. It exists so a client can
|
||||||
|
tell a live stream from a dead one: an `EventSource` whose connection has been
|
||||||
|
idle-closed by a proxy (or that resumed from sleep on a stale socket) keeps
|
||||||
|
delivering nothing without ever firing `onerror`. Clients that care should treat
|
||||||
|
silence longer than about three intervals as a dead stream and reconnect, which
|
||||||
|
is what the bundled frontend does.
|
||||||
|
|
||||||
|
This replaced a `:keepalive` SSE **comment**, which served the same
|
||||||
|
proxy-flushing purpose but is invisible to `EventSource` by spec and so could
|
||||||
|
never be observed by a client. Consumers written against the old behavior are
|
||||||
|
unaffected: `EventSource` dispatches only events that have a registered
|
||||||
|
listener, so an unknown event name is dropped.
|
||||||
|
|
||||||
## Consuming from JavaScript
|
## Consuming from JavaScript
|
||||||
|
|
||||||
The bundled frontend reads responses through `_apiJson()`
|
The bundled frontend reads responses through `_apiJson()`
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
### Cron jobs
|
### Cron jobs
|
||||||
|
|
||||||
**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
|
**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini/antigravity/pi agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
|
||||||
|
|
||||||
### Unified session list and Session Manager
|
### Unified session list and Session Manager
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||||
|
|
||||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and same-row pairs get a shallow U-bridge HANGING BELOW the strip (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting), while a wrapped strip (`tabs-two-rows`/`tabs-auto-wrap`) falls back to the vertical bezier.
|
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
|
||||||
|
|
||||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
||||||
|
|
||||||
**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 stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
|||||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
|
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
|
||||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`) |
|
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) |
|
||||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||||
|
|
||||||
## Performance and limits
|
## Performance and limits
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Cron Jobs — User & Operator Guide
|
# Cron Jobs — User & Operator Guide
|
||||||
|
|
||||||
Codeman's **Cron** feature lets you save named, recurring jobs that automatically
|
Codeman's **Cron** feature lets you save named, recurring jobs that automatically
|
||||||
spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini) session on a schedule and
|
spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) session on a schedule and
|
||||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||||
|
|
||||||
|
|||||||
@@ -377,8 +377,8 @@ Every one of these has cost somebody real time.
|
|||||||
a multi-word match is unreliable there. Match one short space-free token, ideally
|
a multi-word match is unreliable there. Match one short space-free token, ideally
|
||||||
one you printed yourself, and keep it out of the typed line (your own keystrokes
|
one you printed yourself, and keep it out of the typed line (your own keystrokes
|
||||||
echo into the stream).
|
echo into the stream).
|
||||||
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini` or
|
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini`,
|
||||||
`antigravity` sessions.** They come from Claude Code hooks, which no other mode
|
`antigravity` or `pi` sessions.** They come from Claude Code hooks, which no other mode
|
||||||
installs, so only `idle`, `working` and `exit` exist there. Asking for them
|
installs, so only `idle`, `working` and `exit` exist there. Asking for them
|
||||||
explicitly is a `400`; omitting `until` is safe, since the server drops them from
|
explicitly is a `400`; omitting `until` is safe, since the server drops them from
|
||||||
the default set and echoes what it actually waited on as `wait.until`. Even in
|
the default set and echoes what it actually waited on as `wait.until`. Even in
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ A prediction takes 5-90 seconds and costs real tokens; one runs per session at a
|
|||||||
|
|
||||||
Capture reads the Claude session transcript, not your keystrokes: when a user turn lands in the transcript, its text is folded into the case's profile. Filters applied on the way in:
|
Capture reads the Claude session transcript, not your keystrokes: when a user turn lands in the transcript, its text is folded into the case's profile. Filters applied on the way in:
|
||||||
|
|
||||||
- **Claude-mode sessions only.** Shell, OpenCode, Codex, Gemini, and Antigravity sessions are never captured (they have no transcript watcher).
|
- **Claude-mode sessions only.** Shell, OpenCode, Codex, Gemini, Antigravity, and Pi sessions are never captured (they have no transcript watcher).
|
||||||
- Tool results, local slash-command echo (`/model` and friends), system wrappers, and interrupt markers are skipped.
|
- Tool results, local slash-command echo (`/model` and friends), system wrappers, and interrupt markers are skipped.
|
||||||
- Entries shorter than 3 characters are skipped (menu digits, Esc artifacts).
|
- Entries shorter than 3 characters are skipped (menu digits, Esc artifacts).
|
||||||
- Consecutive duplicates collapse (auto-resume's "continue" spam counts once per run).
|
- Consecutive duplicates collapse (auto-resume's "continue" spam counts once per run).
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ TOCTOU window.
|
|||||||
| Route | Cap | Notes |
|
| Route | Cap | Notes |
|
||||||
|-------|-----|-------|
|
|-------|-----|-------|
|
||||||
| `file-content` | 10 MB | text preview |
|
| `file-content` | 10 MB | text preview |
|
||||||
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses** |
|
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
|
||||||
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
|
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
|
||||||
|
|
||||||
### SVG / content‑type XSS
|
### SVG / content‑type XSS
|
||||||
|
|||||||
@@ -93,17 +93,37 @@ The path math itself lives in `constants.js` as a pure
|
|||||||
### 4.2 Geometry
|
### 4.2 Geometry
|
||||||
|
|
||||||
Both endpoints are tabs in one horizontal strip, so the subagent shape (tab-bottom →
|
Both endpoints are tabs in one horizontal strip, so the subagent shape (tab-bottom →
|
||||||
window-top) does not apply. Two cases:
|
window-top) does not apply. **One case**, a **U-bridge hanging below the strip** that
|
||||||
|
touches both tabs on their bottom edge:
|
||||||
|
|
||||||
- **Same row** (the normal case): a shallow **U-bridge hanging below the strip**.
|
```
|
||||||
`y0 = max(parent.bottom, child.bottom)`, dip
|
y0 = max(parent.bottom, child.bottom)
|
||||||
`d = clamp(14 + |x2 - x1| * 0.06, 16, 44) + depth * 6`, path
|
d = clamp(14 + |x2 - x1| * 0.085, 22, 104) + depth * 8 + |child.bottom - parent.bottom|
|
||||||
`M x1 y0 C x1 y0+d, x2 y0+d, x2 y0`. `depth` is the child's index among its
|
path: M x1 parent.bottom C x1 y0+d, x2 y0+d, x2 child.bottom
|
||||||
siblings, so several children of one parent **nest** instead of overprinting.
|
```
|
||||||
- **Different rows** (`tabs-two-rows` / `tabs-auto-wrap` on desktop): the existing
|
|
||||||
vertical bezier from parent-bottom-center to child-top-center.
|
|
||||||
|
|
||||||
A small `<circle r="3">` at the child end marks direction (an SVG `marker` would need a
|
`depth` is the child's index among its siblings, so several children of one parent
|
||||||
|
**nest** instead of overprinting.
|
||||||
|
|
||||||
|
> **Superseded (2026-08-14): the two shapes this section used to specify.** The dip was
|
||||||
|
> `clamp(14 + span * 0.06, 16, 44) + depth * 6`, and a wrapped strip
|
||||||
|
> (`tabs-two-rows` / `tabs-auto-wrap`) got its own parent-bottom → child-**top** bezier.
|
||||||
|
> Both were tuned against two tabs side by side and failed at the distances the feature
|
||||||
|
> is used at:
|
||||||
|
>
|
||||||
|
> - a skill worker is appended to the **end** of the strip, so the real span is
|
||||||
|
> 800-1500px, where a 44px cap is a 33px sag, i.e. a line that reads as straight and
|
||||||
|
> crosses the terminal instead of bracketing under the strip;
|
||||||
|
> - and when the strip wraps, parent-bottom (34) to child-top (48) leaves **14px** to
|
||||||
|
> bend in, so the arc was a flat line hidden in the row gap, with siblings drawn on
|
||||||
|
> top of each other. Reported as *"they connect already, but the lines are straight
|
||||||
|
> and not easy visible"*.
|
||||||
|
>
|
||||||
|
> Anchoring both ends at the tab bottoms and hanging the control points below the
|
||||||
|
> **lower** row gives the wrapped case the same bracket as the flat one, and removes the
|
||||||
|
> branch. Pinned by `test/session-lineage-lines.test.ts`.
|
||||||
|
|
||||||
|
A small `<circle r="3.5">` at the child end marks direction (it breathes to 4.5 while that worker is busy) (an SVG `marker` would need a
|
||||||
`<defs>` block and fights `stroke-dasharray`).
|
`<defs>` block and fights `stroke-dasharray`).
|
||||||
|
|
||||||
Each path gets `class="connection-line lineage-line"`, `data-parent-tab`,
|
Each path gets `class="connection-line lineage-line"`, `data-parent-tab`,
|
||||||
@@ -139,8 +159,12 @@ callers are cheap. Needed:
|
|||||||
### 4.5 Styling
|
### 4.5 Styling
|
||||||
|
|
||||||
`.connection-line.lineage-line`: violet stroke from a `--lineage-line` token,
|
`.connection-line.lineage-line`: violet stroke from a `--lineage-line` token,
|
||||||
`stroke-width: 2`, `dasharray 4 4`, `opacity: .55`, softer glow than the subagent lines
|
`stroke-width: 2.5`, `dasharray 5 5`, `opacity: .72` (`.95` while the child works),
|
||||||
so the two layers read as different things. Trap to respect: the skin block nests under
|
softer than the subagent lines so the two layers read as different things, but the
|
||||||
|
contrast comes from a **second, wider glow** rather than more weight, because the first
|
||||||
|
cut (2px / `4 4` / `.55` / one 5px glow) disappeared into terminal text on a real 1080p
|
||||||
|
desktop. `lineage-flow` marches by two dash cycles, so it moves with the dash array
|
||||||
|
(`5 5` → `-20`). Trap to respect: the skin block nests under
|
||||||
`html:not([data-skin="og"])`, so a bare `.lineage-line` rule inside it would outrank the
|
`html:not([data-skin="og"])`, so a bare `.lineage-line` rule inside it would outrank the
|
||||||
base rule at higher specificity. **Define the color as a token per skin, keep exactly
|
base rule at higher specificity. **Define the color as a token per skin, keep exactly
|
||||||
one `.lineage-line` rule.** Light skins get a darker stroke.
|
one `.lineage-line` rule.** Light skins get a darker stroke.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.17.0",
|
"version": "1.18.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.17.0",
|
"version": "1.18.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.17.0",
|
"version": "1.18.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",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Pure HTTP byte-range parsing for the raw file-serving routes.
|
||||||
|
*
|
||||||
|
* Why this exists: a `<video>`/`<audio>` element is only seekable when the
|
||||||
|
* server advertises `Accept-Ranges: bytes` and answers `Range` requests with
|
||||||
|
* `206 Partial Content`. Serving the whole file with `200 OK` (what file-raw
|
||||||
|
* did) makes Chrome report `video.seekable === [0, 0]`, so the scrub bar is
|
||||||
|
* inert and `currentTime = x` is silently ignored; Safari refuses to start the
|
||||||
|
* media at all. Parsing lives here, away from the IO, so the edge cases
|
||||||
|
* (suffix ranges, open-ended ranges, oversized specs, empty files) are unit
|
||||||
|
* testable without touching the filesystem.
|
||||||
|
*
|
||||||
|
* Deliberately single-range only: multi-range responses require a
|
||||||
|
* `multipart/byteranges` body that no media element asks for, and RFC 9110
|
||||||
|
* §14.2 lets a server ignore a Range it does not want to honor and answer with
|
||||||
|
* the full representation. Same for syntactically invalid specs — those are
|
||||||
|
* ignored (200), while a syntactically valid but out-of-bounds spec is the one
|
||||||
|
* case that earns a 416.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Result of parsing a `Range` header against a known representation size. */
|
||||||
|
export type ByteRangeRequest =
|
||||||
|
/** No range, an unsupported unit, or a malformed spec — serve the whole file with 200. */
|
||||||
|
| { kind: 'full' }
|
||||||
|
/** A satisfiable single range, inclusive on both ends — serve 206. */
|
||||||
|
| { kind: 'partial'; start: number; end: number }
|
||||||
|
/** Syntactically valid but outside the representation — serve 416. */
|
||||||
|
| { kind: 'unsatisfiable' };
|
||||||
|
|
||||||
|
const BYTES_RANGE_SPEC = /^(\d*)-(\d*)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digits → number, bounded. A range spec is arbitrary client input, so a
|
||||||
|
* 100-digit first-byte-pos must not become `Infinity` (which would then flow
|
||||||
|
* into a `createReadStream` offset). Anything longer than a safe integer is
|
||||||
|
* clamped, which the callers then treat as "past the end of the file".
|
||||||
|
*/
|
||||||
|
function parseBoundedInt(digits: string): number {
|
||||||
|
return digits.length > 15 ? Number.MAX_SAFE_INTEGER : Number(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `Range` request header against a file of `size` bytes.
|
||||||
|
*
|
||||||
|
* @param header - Raw header value (`req.headers.range`). Arrays (a duplicated
|
||||||
|
* header) are ignored rather than guessed at.
|
||||||
|
* @param size - Size of the full representation in bytes.
|
||||||
|
*/
|
||||||
|
export function parseByteRange(header: string | string[] | undefined, size: number): ByteRangeRequest {
|
||||||
|
if (typeof header !== 'string') return { kind: 'full' };
|
||||||
|
|
||||||
|
const trimmed = header.trim();
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 0 || trimmed.slice(0, eq).trim().toLowerCase() !== 'bytes') return { kind: 'full' };
|
||||||
|
|
||||||
|
const spec = trimmed.slice(eq + 1).trim();
|
||||||
|
// Multi-range requests would need a multipart/byteranges body; ignoring the
|
||||||
|
// header and serving the full representation is a valid answer.
|
||||||
|
if (!spec || spec.includes(',')) return { kind: 'full' };
|
||||||
|
|
||||||
|
const match = BYTES_RANGE_SPEC.exec(spec);
|
||||||
|
if (!match) return { kind: 'full' };
|
||||||
|
const [, rawStart, rawEnd] = match;
|
||||||
|
if (!rawStart && !rawEnd) return { kind: 'full' };
|
||||||
|
|
||||||
|
// Suffix range: `bytes=-N` means the LAST N bytes, not "from N to the end".
|
||||||
|
if (!rawStart) {
|
||||||
|
const suffix = parseBoundedInt(rawEnd);
|
||||||
|
if (suffix === 0 || size === 0) return { kind: 'unsatisfiable' };
|
||||||
|
return { kind: 'partial', start: Math.max(0, size - suffix), end: size - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = parseBoundedInt(rawStart);
|
||||||
|
if (size === 0 || start >= size) return { kind: 'unsatisfiable' };
|
||||||
|
|
||||||
|
// `bytes=N-` — from N to the end of the file. This is the form Chrome opens
|
||||||
|
// a media element with (`bytes=0-`), so it must answer 206, not 200.
|
||||||
|
if (!rawEnd) return { kind: 'partial', start, end: size - 1 };
|
||||||
|
|
||||||
|
const requestedEnd = parseBoundedInt(rawEnd);
|
||||||
|
// last-byte-pos < first-byte-pos is an invalid spec, not an unsatisfiable
|
||||||
|
// one: RFC 9110 §14.1.1 says the whole header field is then ignored.
|
||||||
|
if (requestedEnd < start) return { kind: 'full' };
|
||||||
|
|
||||||
|
return { kind: 'partial', start, end: Math.min(requestedEnd, size - 1) };
|
||||||
|
}
|
||||||
+225
-13
@@ -684,6 +684,17 @@ class CodemanApp {
|
|||||||
this.maxReconnectAttempts = 10;
|
this.maxReconnectAttempts = 10;
|
||||||
this.isOnline = navigator.onLine;
|
this.isOnline = navigator.onLine;
|
||||||
|
|
||||||
|
// SSE staleness watchdog. An EventSource that stops delivering does not
|
||||||
|
// always error (a proxy that idle-closed it, a resumed laptop), so
|
||||||
|
// `onerror` never fires and every SSE-driven surface freezes silently.
|
||||||
|
// The server heartbeats every 15s; going quiet for three of them means the
|
||||||
|
// stream is a zombie and has to be rebuilt. The decision is pure
|
||||||
|
// (computeSseStale in constants.js); these are its inputs. The threshold
|
||||||
|
// is an instance field so a browser test can shrink it.
|
||||||
|
this._sseLastMessageAt = 0;
|
||||||
|
this._sseStaleTimeoutMs = window.CodemanSseStale?.TIMEOUT_MS ?? 45000;
|
||||||
|
this._sseStaleWatchdog = null;
|
||||||
|
|
||||||
// Connection-loss UI (banner + full-screen overlay). The decision itself is
|
// Connection-loss UI (banner + full-screen overlay). The decision itself is
|
||||||
// pure and lives in constants.js (computeConnectionLossUi); these are just
|
// pure and lives in constants.js (computeConnectionLossUi); these are just
|
||||||
// its inputs. `_connDownSince` is the timestamp the transport LEFT the
|
// its inputs. `_connDownSince` is the timestamp the transport LEFT the
|
||||||
@@ -719,6 +730,11 @@ class CodemanApp {
|
|||||||
window.addEventListener('pagehide', () => this._persistReliableNow());
|
window.addEventListener('pagehide', () => this._persistReliableNow());
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||||
|
// A background tab's timers are throttled, so the 5s watchdog may not
|
||||||
|
// have run for minutes, and a wake/unlock is exactly when a stream
|
||||||
|
// comes back zombie. Checking here is what makes recovery feel instant
|
||||||
|
// instead of up to a full timeout late.
|
||||||
|
else this._checkSseStale();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
||||||
@@ -1421,6 +1437,14 @@ class CodemanApp {
|
|||||||
// Clear any pending reconnect timeout to prevent duplicate connections
|
// Clear any pending reconnect timeout to prevent duplicate connections
|
||||||
this._clearTimer('sseReconnectTimeout');
|
this._clearTimer('sseReconnectTimeout');
|
||||||
|
|
||||||
|
// Same discipline for the staleness watchdog: connectSSE() runs on every
|
||||||
|
// reconnect and is the only teardown path this page-lifetime interval has,
|
||||||
|
// so clearing it anywhere else (or not at all) stacks intervals.
|
||||||
|
if (this._sseStaleWatchdog) {
|
||||||
|
clearInterval(this._sseStaleWatchdog);
|
||||||
|
this._sseStaleWatchdog = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
||||||
if (this._sseListenerCleanup) {
|
if (this._sseListenerCleanup) {
|
||||||
this._sseListenerCleanup();
|
this._sseListenerCleanup();
|
||||||
@@ -1448,11 +1472,20 @@ class CodemanApp {
|
|||||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||||
|
|
||||||
// Store all event listeners for cleanup on reconnect
|
// Store all event listeners for cleanup on reconnect.
|
||||||
|
//
|
||||||
|
// Every handler is wrapped so ANY frame that arrives stamps the liveness
|
||||||
|
// clock the staleness watchdog reads. Doing it here (rather than at the
|
||||||
|
// three separate registration sites below) is what keeps a future
|
||||||
|
// addListener() call from silently opting out of it.
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
const addListener = (event, handler) => {
|
const addListener = (event, handler) => {
|
||||||
this.eventSource.addEventListener(event, handler);
|
const stamped = (e) => {
|
||||||
listeners.push({ event, handler });
|
this._sseLastMessageAt = Date.now();
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
this.eventSource.addEventListener(event, stamped);
|
||||||
|
listeners.push({ event, handler: stamped });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create cleanup function to remove all listeners
|
// Create cleanup function to remove all listeners
|
||||||
@@ -1467,6 +1500,10 @@ class CodemanApp {
|
|||||||
|
|
||||||
this.eventSource.onopen = () => {
|
this.eventSource.onopen = () => {
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
// Start the liveness clock here, not at the first frame: the watchdog
|
||||||
|
// only ever fires while the status is 'connected', and this is the
|
||||||
|
// moment that becomes true.
|
||||||
|
this._sseLastMessageAt = Date.now();
|
||||||
this.setConnectionStatus('connected');
|
this.setConnectionStatus('connected');
|
||||||
};
|
};
|
||||||
this.eventSource.onerror = () => {
|
this.eventSource.onerror = () => {
|
||||||
@@ -1614,6 +1651,52 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
this._onSessionListMaybeChanged();
|
this._onSessionListMaybeChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Liveness heartbeat. The handler is deliberately empty: the whole point
|
||||||
|
// is the stamp inherited from addListener's wrapper. It still has to be
|
||||||
|
// REGISTERED: EventSource only dispatches named events that have a
|
||||||
|
// listener, so without this the frame arrives on the wire and is dropped
|
||||||
|
// before it can prove the stream is alive.
|
||||||
|
addListener(SSE_EVENTS.HEARTBEAT, () => {});
|
||||||
|
|
||||||
|
// Watchdog: a stream that goes quiet without erroring is invisible to
|
||||||
|
// onerror, so poll the pure staleness policy and rebuild the connection
|
||||||
|
// ourselves. 5s granularity against a 45s threshold: cheap, and it keeps
|
||||||
|
// the worst-case detection lag well under a heartbeat interval.
|
||||||
|
this._sseStaleWatchdog = setInterval(() => this._checkSseStale(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a reconnect if the SSE stream has gone quiet while still claiming to
|
||||||
|
* be connected. Called by the 5s watchdog and on tab-visible.
|
||||||
|
*
|
||||||
|
* Recovery needs no new sync path: the reconnect re-runs `handleInit`, which
|
||||||
|
* already calls `_resetAllAppState()` and rebuilds everything from the
|
||||||
|
* server. The connection-loss UI needs nothing either: `connectSSE()` sets
|
||||||
|
* status 'connecting' (reconnectAttempts was zeroed by onopen), and the 2.5s
|
||||||
|
* grace in computeConnectionLossUi means a stream that heals in 200ms shows
|
||||||
|
* nothing at all.
|
||||||
|
*/
|
||||||
|
_checkSseStale() {
|
||||||
|
const policy = window.CodemanSseStale;
|
||||||
|
if (!policy) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const stale = policy.compute({
|
||||||
|
lastMessageAt: this._sseLastMessageAt,
|
||||||
|
now,
|
||||||
|
status: this._connectionStatus,
|
||||||
|
isOnline: this.isOnline,
|
||||||
|
timeoutMs: this._sseStaleTimeoutMs,
|
||||||
|
});
|
||||||
|
if (!stale) return;
|
||||||
|
// If a middlebox ever strips or delays heartbeats, the failure mode is
|
||||||
|
// "silently reconnects every 45s", and a field report of that would be
|
||||||
|
// undebuggable without this line.
|
||||||
|
console.log(
|
||||||
|
`[SSE] stream stale: no frame for ${now - this._sseLastMessageAt}ms ` +
|
||||||
|
`(threshold ${this._sseStaleTimeoutMs}ms), forcing reconnect`
|
||||||
|
);
|
||||||
|
this.connectSSE();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -2113,14 +2196,46 @@ class CodemanApp {
|
|||||||
if (!this.activeSessionId || !this.terminal) return;
|
if (!this.activeSessionId || !this.terminal) return;
|
||||||
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||||
if (this._isLoadingBuffer) return;
|
if (this._isLoadingBuffer) return;
|
||||||
|
const sessionId = this.activeSessionId;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
// Recovery should restore the WHOLE picture, so ask for full history
|
||||||
const data = (await res.json())?.data ?? {};
|
// rather than a tail. Measured on a 900-line shell pane: the tail rewrite
|
||||||
|
// replaced an 869-row buffer with 158 rows, so every backpressure refresh
|
||||||
|
// silently destroyed most of the scrollback it was meant to repair.
|
||||||
|
//
|
||||||
|
// A repaint-mode pane is the opposite case (tmux keeps ~one frame for it),
|
||||||
|
// so the full capture can be SMALLER than what xterm already holds. Reuse
|
||||||
|
// the same downgrade guard as the scroll-to-top re-pull and fall back to
|
||||||
|
// the historical tail there, leaving that case exactly as it was.
|
||||||
|
let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||||
|
let data = (await res.json())?.data ?? {};
|
||||||
|
if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||||
|
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||||
|
data = (await res.json())?.data ?? {};
|
||||||
|
}
|
||||||
|
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||||
|
// history into the terminal the user is now looking at. The window is two
|
||||||
|
// fetches wide in the fallback case, so this guard is not optional.
|
||||||
|
if (this.activeSessionId !== sessionId) return;
|
||||||
if (data.terminalBuffer) {
|
if (data.terminalBuffer) {
|
||||||
|
// This refresh is SERVER-triggered, so a user quietly reading scrollback
|
||||||
|
// did not ask for it and must not be dragged to the bottom by it (#259).
|
||||||
|
// The rewrite replaces the buffer, so an absolute viewportY is
|
||||||
|
// meaningless across it — distance from the bottom is what survives.
|
||||||
|
const before = this.terminal.buffer?.active;
|
||||||
|
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
|
||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.reset();
|
this.terminal.reset();
|
||||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||||
this.terminal.scrollToBottom();
|
// A tail fetch can be partial, and the banner would otherwise keep
|
||||||
|
// describing the pre-refresh buffer (#258).
|
||||||
|
this._setHistoryTruncation(sessionId, data);
|
||||||
|
const target = computeRewriteScrollLine({
|
||||||
|
linesFromBottom,
|
||||||
|
baseY: this.terminal.buffer?.active?.baseY ?? 0,
|
||||||
|
});
|
||||||
|
if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom();
|
||||||
|
else this.terminal.scrollToLine(target);
|
||||||
// Re-position local echo overlay at new prompt location
|
// Re-position local echo overlay at new prompt location
|
||||||
this._localEchoOverlay?.rerender();
|
this._localEchoOverlay?.rerender();
|
||||||
// Resize PTY to match actual browser dimensions (critical for OpenCode
|
// Resize PTY to match actual browser dimensions (critical for OpenCode
|
||||||
@@ -4426,28 +4541,36 @@ class CodemanApp {
|
|||||||
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
|
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
|
||||||
* every scroll-up (issue #205, round 2).
|
* every scroll-up (issue #205, round 2).
|
||||||
*/
|
*/
|
||||||
async _maybeRefetchFullHistory() {
|
async _maybeRefetchFullHistory({ force = false } = {}) {
|
||||||
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 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.
|
||||||
|
// `force` is the user pressing "Load full history" (#258): they asked once,
|
||||||
|
// explicitly, so the scroll-gesture cooldown does not apply. The downgrade
|
||||||
|
// guard below still does — a forced pull must not destroy history either.
|
||||||
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
|
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
|
||||||
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
|
if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
|
||||||
this._fullHistoryRepullAt.set(sessionId, now);
|
this._fullHistoryRepullAt.set(sessionId, now);
|
||||||
this._fullHistoryRepullInFlight = true;
|
this._fullHistoryRepullInFlight = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||||
const buffer = (await res.json())?.data?.terminalBuffer;
|
const payload = (await res.json())?.data ?? {};
|
||||||
|
const buffer = payload.terminalBuffer;
|
||||||
// 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)) {
|
||||||
(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
|
||||||
|
// nothing further to offer and the indicator must stop promising it.
|
||||||
|
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
@@ -4468,6 +4591,89 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record how much history a replay actually carried, and refresh the banner.
|
||||||
|
*
|
||||||
|
* Called from every path that writes a fetched buffer into xterm. Keyed by
|
||||||
|
* session because the banner describes the ACTIVE tab and a background fetch
|
||||||
|
* must not relabel it.
|
||||||
|
*/
|
||||||
|
_setHistoryTruncation(sessionId, payload = {}) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
(this._historyTruncation ||= new Map()).set(sessionId, {
|
||||||
|
truncated: !!payload.truncated,
|
||||||
|
reason: payload.truncationReason ?? null,
|
||||||
|
source: payload.source ?? null,
|
||||||
|
fullSize: payload.fullSize ?? 0,
|
||||||
|
retainedBytes: payload.retainedBytes ?? 0,
|
||||||
|
// Set once a full-history pull has been refused as a downgrade: the
|
||||||
|
// browser holds more than the server can return, so there is no more.
|
||||||
|
exhausted: !!payload.exhausted,
|
||||||
|
});
|
||||||
|
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop banner state for a session that is going away. */
|
||||||
|
_clearHistoryTruncation(sessionId) {
|
||||||
|
this._historyTruncation?.delete(sessionId);
|
||||||
|
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint the partial-history banner for the active session.
|
||||||
|
*
|
||||||
|
* Three distinct states, because "we tailed for speed" and "the oldest output
|
||||||
|
* is gone forever" are not the same message and the old single boolean could
|
||||||
|
* not tell them apart:
|
||||||
|
* - recoverable → offer to load the rest
|
||||||
|
* - exhausted → say so plainly, offer nothing
|
||||||
|
* - at the limit → the full capture ITSELF hit the byte ceiling
|
||||||
|
*/
|
||||||
|
_renderHistoryTruncationBanner() {
|
||||||
|
const bar = document.getElementById('historyTruncationBar');
|
||||||
|
if (!bar) return;
|
||||||
|
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
|
||||||
|
const notice = computeHistoryTruncationNotice(state || {});
|
||||||
|
if (!notice.visible) {
|
||||||
|
bar.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.textContent = '';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'history-trunc-text';
|
||||||
|
label.textContent = notice.message;
|
||||||
|
bar.appendChild(label);
|
||||||
|
|
||||||
|
if (notice.canLoadMore) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'history-trunc-load';
|
||||||
|
btn.textContent = 'Load full history';
|
||||||
|
btn.onclick = () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Loading…';
|
||||||
|
// Forced: the cooldown exists to throttle scroll gestures, not choices.
|
||||||
|
this._maybeRefetchFullHistory({ force: true }).finally(() => {
|
||||||
|
this._renderHistoryTruncationBanner();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bar.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismiss = document.createElement('button');
|
||||||
|
dismiss.type = 'button';
|
||||||
|
dismiss.className = 'history-trunc-dismiss';
|
||||||
|
dismiss.setAttribute('aria-label', 'Dismiss history notice');
|
||||||
|
dismiss.textContent = '×';
|
||||||
|
dismiss.onclick = () => {
|
||||||
|
bar.hidden = true;
|
||||||
|
};
|
||||||
|
bar.appendChild(dismiss);
|
||||||
|
|
||||||
|
bar.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
_shouldFocusTerminalForTabSwitch() {
|
_shouldFocusTerminalForTabSwitch() {
|
||||||
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
|
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -4524,6 +4730,10 @@ class CodemanApp {
|
|||||||
|
|
||||||
this._cleanupPreviousSession(sessionId);
|
this._cleanupPreviousSession(sessionId);
|
||||||
this.activeSessionId = sessionId;
|
this.activeSessionId = sessionId;
|
||||||
|
// Repaint the partial-history banner for the tab being switched TO. The
|
||||||
|
// replay paths refresh it when their fetch lands; without this the previous
|
||||||
|
// session's notice stays on screen until then (#258).
|
||||||
|
this._renderHistoryTruncationBanner();
|
||||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||||
// Narrow SSE filter to the active session — server stops streaming
|
// Narrow SSE filter to the active session — server stops streaming
|
||||||
// session:terminal events for other sessions to this client. Cuts
|
// session:terminal events for other sessions to this client. Cuts
|
||||||
@@ -4775,10 +4985,11 @@ class CodemanApp {
|
|||||||
_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');
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
// Show truncation indicator if buffer was cut
|
// Truncation is reported OUT OF BAND (#258). This used to write a grey
|
||||||
if (data.truncated) {
|
// "... earlier output truncated ..." line into the
|
||||||
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
|
// terminal itself, which scrolls away with the output it describes,
|
||||||
}
|
// cannot be actioned, and is indistinguishable from real CLI output.
|
||||||
|
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);
|
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||||
if (this._isStaleSelect(selectGen)) {
|
if (this._isStaleSelect(selectGen)) {
|
||||||
@@ -4960,6 +5171,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
this.terminalBuffers.delete(sessionId);
|
this.terminalBuffers.delete(sessionId);
|
||||||
this.terminalBufferCache.delete(sessionId);
|
this.terminalBufferCache.delete(sessionId);
|
||||||
|
this._clearHistoryTruncation(sessionId);
|
||||||
this._xtermSnapshots?.delete(sessionId);
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
|
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
|
||||||
|
|
||||||
|
|||||||
+174
-35
@@ -201,24 +201,43 @@ function computeTabScrollLeft(input) {
|
|||||||
// spawned (a worker started through the codeman agent skill, which passes its own
|
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||||||
//
|
//
|
||||||
// Two shapes, because both endpoints live in ONE horizontal strip and the subagent
|
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
|
||||||
// shape (tab-bottom → window-top) has nothing to aim at:
|
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
||||||
// - same row: a shallow U-bridge HANGING BELOW the strip, so it reads as a
|
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
||||||
// bracket joining two tabs rather than as a line crossing them. The dip grows
|
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
||||||
// with horizontal distance and with `depth` (the child's index among its
|
// horizontal distance and with `depth` (the child's index among its siblings), so
|
||||||
// siblings), so several children of one parent nest instead of overprinting.
|
// several children of one parent nest instead of overprinting.
|
||||||
// - different rows (desktop `tabs-two-rows` / `tabs-auto-wrap`): the vertical
|
//
|
||||||
// bezier the subagent lines already use, parent edge → child edge.
|
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
||||||
|
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
||||||
|
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
||||||
|
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
||||||
|
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
||||||
|
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
||||||
|
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
||||||
|
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
||||||
|
// row once, and comes back up under the child. Same formula, no branch.
|
||||||
//
|
//
|
||||||
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
|
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
|
||||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||||
|
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and the first shipped numbers were tuned
|
||||||
|
// against two tabs sitting side by side. A worker the agent skill starts is appended
|
||||||
|
// to the END of the strip, so the real span between a lead and its worker is 800-1500px,
|
||||||
|
// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as
|
||||||
|
// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now
|
||||||
|
// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket
|
||||||
|
// survives the distance the feature is actually used at. The ceiling is what keeps a
|
||||||
|
// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the
|
||||||
|
// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get.
|
||||||
const LINEAGE_DIP_BASE_PX = 14;
|
const LINEAGE_DIP_BASE_PX = 14;
|
||||||
const LINEAGE_DIP_PER_PX = 0.06;
|
const LINEAGE_DIP_PER_PX = 0.085;
|
||||||
const LINEAGE_DIP_MIN_PX = 16;
|
const LINEAGE_DIP_MIN_PX = 22;
|
||||||
const LINEAGE_DIP_MAX_PX = 44;
|
const LINEAGE_DIP_MAX_PX = 104;
|
||||||
const LINEAGE_SIBLING_STEP_PX = 6;
|
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||||
|
// apart bled into one thick band instead of reading as three separate lines.
|
||||||
|
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||||
|
|
||||||
function computeLineagePath(input) {
|
function computeLineagePath(input) {
|
||||||
@@ -250,29 +269,20 @@ function computeLineagePath(input) {
|
|||||||
const cBottom = cTop + ch;
|
const cBottom = cTop + ch;
|
||||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||||
|
|
||||||
let d;
|
// Both ends anchor on the tab BOTTOM, and the control points hang below whichever
|
||||||
let endX;
|
// row is lower, so one formula covers a flat strip and a wrapped one.
|
||||||
let endY;
|
const span = Math.abs(cx - px);
|
||||||
if (sameRow) {
|
const rowDrop = Math.abs(cBottom - pBottom);
|
||||||
const y0 = Math.max(pBottom, cBottom);
|
// ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would
|
||||||
const span = Math.abs(cx - px);
|
// only reach the row gap again. Adding the row offset also keeps the curve clear of
|
||||||
const dip =
|
// the row it crosses instead of grazing its bottom edge.
|
||||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
const dip =
|
||||||
depth * LINEAGE_SIBLING_STEP_PX;
|
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||||
const yc = y0 + dip;
|
depth * LINEAGE_SIBLING_STEP_PX +
|
||||||
d = `M ${r1(px)} ${r1(y0)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(y0)}`;
|
rowDrop;
|
||||||
endX = cx;
|
const yc = Math.max(pBottom, cBottom) + dip;
|
||||||
endY = y0;
|
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||||
} else {
|
return { d, endX: cx, endY: cBottom, sameRow };
|
||||||
const childBelow = cTop + ch / 2 > pTop + ph / 2;
|
|
||||||
const y1 = childBelow ? pBottom : pTop;
|
|
||||||
const y2 = childBelow ? cTop : cBottom;
|
|
||||||
const mid = (y1 + y2) / 2;
|
|
||||||
d = `M ${r1(px)} ${r1(y1)} C ${r1(px)} ${r1(mid)}, ${r1(cx)} ${r1(mid)}, ${r1(cx)} ${r1(y2)}`;
|
|
||||||
endX = cx;
|
|
||||||
endY = y2;
|
|
||||||
}
|
|
||||||
return { d, endX, endY, sameRow };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||||
@@ -379,6 +389,41 @@ function computeConnectionLossUi(input) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSE staleness policy: is this stream a zombie?
|
||||||
|
//
|
||||||
|
// An EventSource that stops delivering does not always error. A proxy that
|
||||||
|
// idle-closed the connection, a laptop resumed from sleep, a tailnet
|
||||||
|
// reconnect: `onerror` never fires, the header dot stays green, and every
|
||||||
|
// SSE-driven surface (tab status dots, sessions created on another device,
|
||||||
|
// renames) freezes until the user reloads. The server writes a
|
||||||
|
// `sse:heartbeat` frame every 15s, so silence longer than three of them means
|
||||||
|
// the stream is dead even though the transport still claims otherwise.
|
||||||
|
//
|
||||||
|
// Stale ONLY when the transport believes it is 'connected': the other states
|
||||||
|
// already have the reconnect/backoff machinery running, and re-firing on top
|
||||||
|
// of them would stack reconnects. That guard is also the loop breaker: a
|
||||||
|
// forced reconnect leaves 'connected' immediately, so the watchdog cannot
|
||||||
|
// fire again while one is in flight. `navigator.onLine === false` is not
|
||||||
|
// staleness either; there is nothing to reconnect to yet.
|
||||||
|
//
|
||||||
|
// Pure: no DOM, no timers, no side effects. `now` is passed in.
|
||||||
|
const SSE_STALE_TIMEOUT_MS = 45000; // three missed 15s heartbeats
|
||||||
|
|
||||||
|
function computeSseStale(input) {
|
||||||
|
const {
|
||||||
|
lastMessageAt = null,
|
||||||
|
now = 0,
|
||||||
|
status = 'connected',
|
||||||
|
isOnline = true,
|
||||||
|
timeoutMs = SSE_STALE_TIMEOUT_MS,
|
||||||
|
} = input || {};
|
||||||
|
if (!isOnline || status !== 'connected') return false;
|
||||||
|
// No frame has ever arrived: `init` lands on connect, so this is a stream
|
||||||
|
// that has not opened yet rather than one that went quiet.
|
||||||
|
if (typeof lastMessageAt !== 'number' || !(lastMessageAt > 0)) return false;
|
||||||
|
return now - lastMessageAt >= timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
@@ -401,6 +446,10 @@ if (typeof window !== 'undefined') {
|
|||||||
compute: computeConnectionLossUi,
|
compute: computeConnectionLossUi,
|
||||||
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
||||||
};
|
};
|
||||||
|
window.CodemanSseStale = {
|
||||||
|
compute: computeSseStale,
|
||||||
|
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
@@ -514,6 +563,9 @@ const SSE_EVENTS = {
|
|||||||
// Core
|
// Core
|
||||||
INIT: 'init',
|
INIT: 'init',
|
||||||
|
|
||||||
|
// Transport
|
||||||
|
HEARTBEAT: 'sse:heartbeat',
|
||||||
|
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
SESSION_CREATED: 'session:created',
|
SESSION_CREATED: 'session:created',
|
||||||
SESSION_UPDATED: 'session:updated',
|
SESSION_UPDATED: 'session:updated',
|
||||||
@@ -743,3 +795,90 @@ function escapeHtml(text) {
|
|||||||
if (typeof text !== 'string') return '';
|
if (typeof text !== 'string') return '';
|
||||||
return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]);
|
return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable byte size for the partial-history banner (#258).
|
||||||
|
*
|
||||||
|
* Deliberately coarse: the banner is telling the user roughly how much of a
|
||||||
|
* transcript they are looking at, not accounting for bytes. Sub-KB amounts read
|
||||||
|
* as "less than 1 KB" rather than an exact count nobody can act on.
|
||||||
|
*
|
||||||
|
* @param {number} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatHistoryBytes(bytes) {
|
||||||
|
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
|
||||||
|
if (n < 1024) return 'less than 1 KB';
|
||||||
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what the partial-history banner should say (#258).
|
||||||
|
*
|
||||||
|
* PURE so the three states can be tested without a DOM. They exist because one
|
||||||
|
* `truncated` boolean could not distinguish messages the user acts on very
|
||||||
|
* differently:
|
||||||
|
* - recoverable: we tailed for speed and the rest is still retained
|
||||||
|
* - atCeiling: the FULL capture itself hit the byte ceiling
|
||||||
|
* - exhausted: a full pull was refused as a downgrade, so this is all there is
|
||||||
|
*
|
||||||
|
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
|
||||||
|
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
|
||||||
|
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
|
||||||
|
*/
|
||||||
|
function computeHistoryTruncationNotice(state = {}) {
|
||||||
|
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
|
||||||
|
|
||||||
|
const retained = Math.max(0, state.retainedBytes || 0);
|
||||||
|
const dropped = Math.max(0, (state.fullSize || 0) - retained);
|
||||||
|
const shown = formatHistoryBytes(retained);
|
||||||
|
// A full-history capture that was STILL capped is already everything tmux
|
||||||
|
// holds, so the remainder is out of reach rather than one request away.
|
||||||
|
const atCeiling = state.source === 'mux-full-history' && state.reason === 'capped';
|
||||||
|
|
||||||
|
if (state.exhausted) {
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
message: `Showing all ${shown} of retained history. Earlier output is no longer kept for this session.`,
|
||||||
|
canLoadMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (atCeiling) {
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
message: `Showing the most recent ${shown}. Earlier output exceeds the retained history limit and cannot be recovered.`,
|
||||||
|
canLoadMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
|
||||||
|
canLoadMore: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to land after a rewrite that REPLACES the whole buffer (#259).
|
||||||
|
*
|
||||||
|
* The backpressure refresh clears the terminal and reloads it from a freshly
|
||||||
|
* fetched capture, so an absolute viewportY captured beforehand means nothing
|
||||||
|
* afterwards: the line it pointed at may not even exist. Distance from the
|
||||||
|
* BOTTOM is the anchor that survives a rewrite, so a reader stays roughly
|
||||||
|
* where they were reading.
|
||||||
|
*
|
||||||
|
* Returns null when the user was following live output, which the caller reads
|
||||||
|
* as "scroll to bottom" — the historical behavior, kept for that case.
|
||||||
|
*
|
||||||
|
* @param {{linesFromBottom?: number, baseY?: number}} input
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function computeRewriteScrollLine(input) {
|
||||||
|
const linesFromBottom = input?.linesFromBottom || 0;
|
||||||
|
if (!(linesFromBottom > 0)) return null;
|
||||||
|
return Math.max(0, (input?.baseY || 0) - linesFromBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||||
|
}
|
||||||
|
|||||||
@@ -310,6 +310,11 @@
|
|||||||
<!-- Main Terminal Area -->
|
<!-- Main Terminal Area -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="terminal-wrap">
|
<div class="terminal-wrap">
|
||||||
|
<!-- Partial-history notice (#258). Lives OUTSIDE the terminal on purpose:
|
||||||
|
the old notice was a grey line written into the scrollback, so it
|
||||||
|
scrolled away with the output it described and could not be acted
|
||||||
|
on. Populated by app.js _renderHistoryTruncationBanner(). -->
|
||||||
|
<div class="history-trunc-bar" id="historyTruncationBar" role="status" aria-live="polite" hidden></div>
|
||||||
<div class="terminal-container" id="terminalContainer"></div>
|
<div class="terminal-container" id="terminalContainer"></div>
|
||||||
<textarea id="cjkInput" rows="1" placeholder="CJK input (Enter = send, Esc = clear)"
|
<textarea id="cjkInput" rows="1" placeholder="CJK input (Enter = send, Esc = clear)"
|
||||||
maxlength="65536" aria-label="CJK IME input field"
|
maxlength="65536" aria-label="CJK IME input field"
|
||||||
|
|||||||
@@ -214,8 +214,13 @@ const KeyboardHandler = {
|
|||||||
keyboardVisible: false,
|
keyboardVisible: false,
|
||||||
initialViewportHeight: 0,
|
initialViewportHeight: 0,
|
||||||
_viewportSettleTimer: null,
|
_viewportSettleTimer: null,
|
||||||
_settleScrollToBottom: false,
|
_settleRestoreScroll: false,
|
||||||
_settlePending: false,
|
_settlePending: false,
|
||||||
|
// Scroll intent captured at the start of a settle cycle (#259). `true` =
|
||||||
|
// following live output, `false` = reading history and _settleAnchorY holds
|
||||||
|
// the top visible line to return to.
|
||||||
|
_settleFollowing: true,
|
||||||
|
_settleAnchorY: null,
|
||||||
|
|
||||||
/** Initialize keyboard handling */
|
/** Initialize keyboard handling */
|
||||||
init() {
|
init() {
|
||||||
@@ -284,8 +289,10 @@ const KeyboardHandler = {
|
|||||||
clearTimeout(this._viewportSettleTimer);
|
clearTimeout(this._viewportSettleTimer);
|
||||||
this._viewportSettleTimer = null;
|
this._viewportSettleTimer = null;
|
||||||
}
|
}
|
||||||
this._settleScrollToBottom = false;
|
this._settleRestoreScroll = false;
|
||||||
this._settlePending = false;
|
this._settlePending = false;
|
||||||
|
this._settleFollowing = true;
|
||||||
|
this._settleAnchorY = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Handle viewport resize (keyboard show/hide) */
|
/** Handle viewport resize (keyboard show/hide) */
|
||||||
@@ -427,7 +434,7 @@ const KeyboardHandler = {
|
|||||||
|
|
||||||
// visualViewport emits multiple heights throughout the OS animation.
|
// visualViewport emits multiple heights throughout the OS animation.
|
||||||
// Re-schedule on every event and fit only after the final height settles.
|
// Re-schedule on every event and fit only after the final height settles.
|
||||||
this._scheduleViewportSettle({ scrollToBottom: true });
|
this._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
|
||||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||||
@@ -442,7 +449,7 @@ const KeyboardHandler = {
|
|||||||
|
|
||||||
this.resetLayout();
|
this.resetLayout();
|
||||||
|
|
||||||
this._scheduleViewportSettle({ scrollToBottom: true });
|
this._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
|
||||||
// Reposition subagent windows to stack from top (below header)
|
// Reposition subagent windows to stack from top (below header)
|
||||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||||
@@ -459,12 +466,46 @@ const KeyboardHandler = {
|
|||||||
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
|
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
|
||||||
* garbles the transcript.
|
* garbles the transcript.
|
||||||
*/
|
*/
|
||||||
_scheduleViewportSettle({ scrollToBottom = false } = {}) {
|
_scheduleViewportSettle({ restoreScroll = false } = {}) {
|
||||||
this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
|
// Capture scroll intent on the FIRST event of a settle cycle, BEFORE any
|
||||||
|
// fit() has reflowed the buffer — a later capture reads an already-moved
|
||||||
|
// viewportY. Issue #259: this path used to force scrollToBottom
|
||||||
|
// unconditionally, so opening the keyboard yanked a user who was reading
|
||||||
|
// history down to the live output.
|
||||||
|
if (!this._settlePending) this._captureTerminalScrollIntent();
|
||||||
|
this._settleRestoreScroll = this._settleRestoreScroll || restoreScroll;
|
||||||
this._settlePending = true;
|
this._settlePending = true;
|
||||||
this._armViewportSettleTimer();
|
this._armViewportSettleTimer();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record whether the terminal is following live output, and if not, the top
|
||||||
|
* visible line to return to. `_settleFollowing` defaults to true so a
|
||||||
|
* terminal we cannot read keeps the historical scroll-to-bottom behavior.
|
||||||
|
*/
|
||||||
|
_captureTerminalScrollIntent() {
|
||||||
|
this._settleFollowing = true;
|
||||||
|
this._settleAnchorY = null;
|
||||||
|
if (typeof app === 'undefined' || !app.terminal?.buffer?.active) return;
|
||||||
|
this._settleFollowing = app.isTerminalAtBottom();
|
||||||
|
if (!this._settleFollowing) this._settleAnchorY = app.terminal.buffer.active.viewportY;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return to the captured anchor after the keyboard reflow. Reflow can rewrap
|
||||||
|
* lines, so the anchor is approximate by construction; it is clamped to the
|
||||||
|
* post-reflow buffer rather than trusted blindly.
|
||||||
|
*/
|
||||||
|
_restoreTerminalScrollIntent() {
|
||||||
|
const term = typeof app !== 'undefined' ? app.terminal : null;
|
||||||
|
const anchor = this._settleAnchorY;
|
||||||
|
if (typeof anchor !== 'number' || typeof term?.scrollToLine !== 'function' || !term.buffer?.active) {
|
||||||
|
term?.scrollToBottom?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
term.scrollToLine(Math.max(0, Math.min(anchor, term.buffer.active.baseY)));
|
||||||
|
},
|
||||||
|
|
||||||
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
|
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
|
||||||
_deferViewportSettle() {
|
_deferViewportSettle() {
|
||||||
if (!this._settlePending) return;
|
if (!this._settlePending) return;
|
||||||
@@ -476,8 +517,8 @@ const KeyboardHandler = {
|
|||||||
this._viewportSettleTimer = setTimeout(() => {
|
this._viewportSettleTimer = setTimeout(() => {
|
||||||
this._viewportSettleTimer = null;
|
this._viewportSettleTimer = null;
|
||||||
this._settlePending = false;
|
this._settlePending = false;
|
||||||
const shouldScrollToBottom = this._settleScrollToBottom;
|
const shouldRestoreScroll = this._settleRestoreScroll;
|
||||||
this._settleScrollToBottom = false;
|
this._settleRestoreScroll = false;
|
||||||
|
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
if (app.fitAddon) {
|
if (app.fitAddon) {
|
||||||
@@ -486,7 +527,12 @@ const KeyboardHandler = {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
if (this.keyboardVisible) this._shrinkPaddingToFit();
|
if (this.keyboardVisible) this._shrinkPaddingToFit();
|
||||||
if (shouldScrollToBottom) app.terminal.scrollToBottom();
|
// Following live output → bottom, as before. Reading history → back to
|
||||||
|
// the pre-reflow anchor instead of being yanked down (#259).
|
||||||
|
if (shouldRestoreScroll) {
|
||||||
|
if (this._settleFollowing === false) this._restoreTerminalScrollIntent();
|
||||||
|
else app.terminal.scrollToBottom();
|
||||||
|
}
|
||||||
app._syncMobileHelperTextareaToCursor?.();
|
app._syncMobileHelperTextareaToCursor?.();
|
||||||
app._localEchoOverlay?.rerender?.();
|
app._localEchoOverlay?.rerender?.();
|
||||||
this._sendTerminalResize();
|
this._sendTerminalResize();
|
||||||
|
|||||||
@@ -3246,6 +3246,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||||
this._resetFilePreviewEdit();
|
this._resetFilePreviewEdit();
|
||||||
|
// Stop whatever the previous preview was playing. Overwriting innerHTML
|
||||||
|
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
|
||||||
|
this._stopFilePreviewMedia();
|
||||||
|
|
||||||
// Show overlay with loading state
|
// Show overlay with loading state
|
||||||
overlay.classList.add('visible');
|
overlay.classList.add('visible');
|
||||||
@@ -3330,10 +3333,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
|
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
|
||||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
} else if (data.type === 'video') {
|
} else if (data.type === 'video') {
|
||||||
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
// playsinline: iOS otherwise hijacks playback into its fullscreen
|
||||||
|
// player, which leaves the overlay behind it and its own close button
|
||||||
|
// as the only way back.
|
||||||
|
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
|
||||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
} else if (data.type === 'audio') {
|
} else if (data.type === 'audio') {
|
||||||
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
|
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
|
||||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
} else if (data.type === 'binary') {
|
} else if (data.type === 'binary') {
|
||||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||||
@@ -3366,9 +3372,36 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (overlay) {
|
if (overlay) {
|
||||||
overlay.classList.remove('visible');
|
overlay.classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
// The overlay is hidden with display:none, which stops it being PAINTED and
|
||||||
|
// nothing else: a <video>/<audio> inside it keeps playing, keeps its audio
|
||||||
|
// audible and keeps streaming from the server. Closing has to stop it.
|
||||||
|
this._stopFilePreviewMedia();
|
||||||
this.filePreviewContent = '';
|
this.filePreviewContent = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause and unload every media element in the preview body, then empty it.
|
||||||
|
*
|
||||||
|
* Removing the element from the DOM is NOT enough — a detached HTMLMediaElement
|
||||||
|
* plays on until it is garbage collected, which is why the X button used to
|
||||||
|
* leave a video audible. pause() stops playback, dropping src + load() aborts
|
||||||
|
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
|
||||||
|
*/
|
||||||
|
_stopFilePreviewMedia() {
|
||||||
|
const bodyEl = this.$('filePreviewBody');
|
||||||
|
if (!bodyEl) return;
|
||||||
|
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
||||||
|
try {
|
||||||
|
media.pause();
|
||||||
|
media.removeAttribute('src');
|
||||||
|
media.load();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to stop preview media:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bodyEl.innerHTML = '';
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
dot.setAttribute('cx', String(geom.endX));
|
dot.setAttribute('cx', String(geom.endX));
|
||||||
dot.setAttribute('cy', String(geom.endY));
|
dot.setAttribute('cy', String(geom.endY));
|
||||||
dot.setAttribute('r', '3');
|
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||||
|
// works, so the two have to be changed together.
|
||||||
|
dot.setAttribute('r', '3.5');
|
||||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||||
dot.setAttribute('data-child-tab', edge.childId);
|
dot.setAttribute('data-child-tab', edge.childId);
|
||||||
svg.appendChild(dot);
|
svg.appendChild(dot);
|
||||||
|
|||||||
@@ -1415,9 +1415,54 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.activeFocusTrap.activate();
|
this.activeFocusTrap.activate();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a name the server has just confirmed into the local session map.
|
||||||
|
*
|
||||||
|
* Both rename surfaces re-render the tab strip from `this.sessions` right
|
||||||
|
* after their PUT, so without this they depended on the `session:updated` SSE
|
||||||
|
* frame to carry their own write back. On a page whose SSE stream has gone
|
||||||
|
* quiet without erroring (a proxy that idle-closed it, a laptop resumed from
|
||||||
|
* sleep) that frame never lands: the PUT stores the new name, the re-render
|
||||||
|
* repaints the stale one, and the rename looks like it did nothing until a
|
||||||
|
* full page reload. The response body is authoritative, so apply it directly.
|
||||||
|
* The SSE frame, when it does arrive, replaces the object with the same name.
|
||||||
|
*/
|
||||||
|
_applyLocalSessionName(sessionId, name) {
|
||||||
|
if (typeof name !== 'string') return;
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
session.name = name;
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
|
// Mirrors _onSessionUpdated: subagent windows cache their parent's name.
|
||||||
|
this.updateSubagentParentNames?.(sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT a session name and return the name the server stored, or null if the
|
||||||
|
* request failed. `_apiPut` swallows network errors into a null Response and
|
||||||
|
* an API-level failure arrives as a non-ok status or `{success:false}`, so a
|
||||||
|
* rename that silently did nothing has to be detected here, not thrown.
|
||||||
|
*/
|
||||||
|
async _putSessionName(sessionId, name) {
|
||||||
|
const res = await this._apiPut(`/api/sessions/${sessionId}/name`, { name });
|
||||||
|
if (!res || !res.ok) return null;
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (payload && payload.success === false) return null;
|
||||||
|
const confirmed = payload?.data?.name;
|
||||||
|
return typeof confirmed === 'string' ? confirmed : name;
|
||||||
|
},
|
||||||
|
|
||||||
async saveSessionName() {
|
async saveSessionName() {
|
||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
const session = this.sessions.get(this.editingSessionId);
|
// Captured: the modal can be closed (or switched to another session) while
|
||||||
|
// the PUT is in flight, and the name belongs to the session that was open.
|
||||||
|
const sessionId = this.editingSessionId;
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
const parsed = session ? parseSessionPrefix(session.name) : null;
|
const parsed = session ? parseSessionPrefix(session.name) : null;
|
||||||
const inputVal = document.getElementById('modalSessionName').value.trim();
|
const inputVal = document.getElementById('modalSessionName').value.trim();
|
||||||
let name;
|
let name;
|
||||||
@@ -1426,11 +1471,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} else {
|
} else {
|
||||||
name = inputVal;
|
name = inputVal;
|
||||||
}
|
}
|
||||||
try {
|
const confirmed = await this._putSessionName(sessionId, name);
|
||||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
if (confirmed === null) {
|
||||||
} catch (err) {
|
this.showToast('Failed to save session name', 'error');
|
||||||
this.showToast('Failed to save session name: ' + err.message, 'error');
|
return;
|
||||||
}
|
}
|
||||||
|
this._applyLocalSessionName(sessionId, confirmed);
|
||||||
|
this.renderSessionTabs();
|
||||||
},
|
},
|
||||||
|
|
||||||
async autoSaveAutoCompact() {
|
async autoSaveAutoCompact() {
|
||||||
@@ -1740,15 +1787,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Skip the API call if the session vanished between focus and blur.
|
// Skip the API call if the session vanished between focus and blur.
|
||||||
const stillExists = this.sessions.has(sessionId);
|
const stillExists = this.sessions.has(sessionId);
|
||||||
if (stillExists && fullName !== session.name) {
|
if (stillExists && fullName !== session.name) {
|
||||||
try {
|
const confirmed = await this._putSessionName(sessionId, fullName);
|
||||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
if (confirmed === null) {
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: fullName })
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
tabName.textContent = originalContent;
|
tabName.textContent = originalContent;
|
||||||
this.showToast('Failed to rename', 'error');
|
this.showToast('Failed to rename', 'error');
|
||||||
|
} else {
|
||||||
|
// The re-render below repaints from this.sessions, so the new name has
|
||||||
|
// to be in the map before it runs (see _applyLocalSessionName()).
|
||||||
|
this._applyLocalSessionName(sessionId, confirmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-render tabs to restore full tab structure
|
// Re-render tabs to restore full tab structure
|
||||||
|
|||||||
+109
-13
@@ -3195,6 +3195,83 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Anchor for the partial-history banner, which overlays rather than stacks. */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Partial-history banner (#258).
|
||||||
|
OVERLAY, not a flex child, on purpose: FitAddon derives rows/cols from the
|
||||||
|
terminal parent's computed height, so a banner that occupied real layout
|
||||||
|
space would SIGWINCH the CLI every time truncation state changed and make
|
||||||
|
Ink repaint the world. Floating it costs a few covered rows at the top,
|
||||||
|
which the dismiss button releases. */
|
||||||
|
.history-trunc-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 6; /* under the local-echo overlay (7), over terminal content */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `.history-trunc-bar` sets display:flex, which outranks the hidden attribute's
|
||||||
|
UA display:none — without this the banner can never be hidden. */
|
||||||
|
.history-trunc-bar[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trunc-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trunc-load {
|
||||||
|
flex: none;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trunc-load:hover:not(:disabled) {
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trunc-load:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trunc-dismiss {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trunc-dismiss:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
@@ -9257,31 +9334,46 @@ kbd {
|
|||||||
light skins included). Do not add a per-skin `.lineage-line` override inside the
|
light skins included). Do not add a per-skin `.lineage-line` override inside the
|
||||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1)
|
html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1)
|
||||||
and would outrank this one from a surprising place. */
|
and would outrank this one from a surprising place. */
|
||||||
|
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
|
||||||
|
with a single 5px glow, which reads on a design mock and disappears on a real
|
||||||
|
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
|
||||||
|
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
|
||||||
|
separate, and the second, wider glow is what buys the contrast instead: it lifts
|
||||||
|
the line off the terminal without thickening it. Dashes scale with the stroke
|
||||||
|
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||||
|
exactly two dash cycles, so it has to move with them. */
|
||||||
.connection-line.lineage-line {
|
.connection-line.lineage-line {
|
||||||
stroke: var(--session-purple, #a98fe0);
|
stroke: var(--session-purple, #a98fe0);
|
||||||
stroke-width: 2;
|
stroke-width: 2.5;
|
||||||
stroke-dasharray: 4 4;
|
stroke-dasharray: 5 5;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
opacity: 0.55;
|
opacity: 0.72;
|
||||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 5px var(--session-purple, #a98fe0));
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-purple, #a98fe0))
|
||||||
|
drop-shadow(0 0 11px var(--session-purple, #a98fe0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||||
|
the line exists to signal, and pairing the brightness with the marching dashes
|
||||||
|
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
|
||||||
|
.connection-line.lineage-line--working {
|
||||||
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-line.lineage-line:hover {
|
.connection-line.lineage-line:hover {
|
||||||
opacity: 0.9;
|
opacity: 1;
|
||||||
stroke-width: 2.5;
|
stroke-width: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineage-line-dot {
|
.lineage-line-dot {
|
||||||
fill: var(--session-purple, #a98fe0);
|
fill: var(--session-purple, #a98fe0);
|
||||||
opacity: 0.7;
|
opacity: 0.85;
|
||||||
filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0));
|
filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0)) drop-shadow(0 0 9px var(--session-purple, #a98fe0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The child end marches while that worker is actually working, so the line
|
/* The child end marches while that worker is actually working, so the line
|
||||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.connection-line.lineage-line--working {
|
.connection-line.lineage-line--working {
|
||||||
opacity: 0.85;
|
|
||||||
animation: lineage-flow 1.1s linear infinite;
|
animation: lineage-flow 1.1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9291,20 +9383,24 @@ kbd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
|
||||||
|
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
|
||||||
|
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
|
||||||
|
iteration. */
|
||||||
@keyframes lineage-flow {
|
@keyframes lineage-flow {
|
||||||
to {
|
to {
|
||||||
stroke-dashoffset: -16;
|
stroke-dashoffset: -20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes lineage-dot-pulse {
|
@keyframes lineage-dot-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
opacity: 0.6;
|
opacity: 0.75;
|
||||||
r: 3;
|
r: 3.5;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
r: 4;
|
r: 4.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2910,10 +2910,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
|
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
|
||||||
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
|
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
|
||||||
let deferred = false;
|
let deferred = false;
|
||||||
// If the user recently scrolled up, remember the viewport so we can restore
|
// If the user is reading history, remember the viewport so we can restore it
|
||||||
// it after the write — Codex status redraws would otherwise jump it.
|
// after the write — Codex status redraws would otherwise jump it.
|
||||||
|
//
|
||||||
|
// Position, not recency (#259). This was gated on _hasRecentUserScrollUp(),
|
||||||
|
// a 1500ms decay window, so a user who scrolled up and then actually READ
|
||||||
|
// for longer than that lost the protection mid-read and got dragged along by
|
||||||
|
// the next repaint. Being scrolled up IS the intent, however long ago it was
|
||||||
|
// expressed; the recency window remains as an extra guard on the sticky
|
||||||
|
// scroll-to-bottom below, where it protects against a mid-flush race.
|
||||||
const preserveViewportY =
|
const preserveViewportY =
|
||||||
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
|
this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null;
|
||||||
|
|
||||||
if (_joinedLen <= MAX_FRAME_BYTES) {
|
if (_joinedLen <= MAX_FRAME_BYTES) {
|
||||||
this.terminal.write(joined);
|
this.terminal.write(joined);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||||
|
import { parseByteRange } from '../http-range.js';
|
||||||
import { isSensitivePath } from '../sensitive-path.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||||
@@ -86,7 +87,13 @@ function buildContentDisposition(disposition: 'inline' | 'attachment', fileName:
|
|||||||
|
|
||||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||||
const headers = reply.getHeaders();
|
const headers = reply.getHeaders();
|
||||||
|
// hijack() answers on reply.raw, which keeps Fastify's own status handling out
|
||||||
|
// of the picture — so a 206 set with reply.code() has to be carried across by
|
||||||
|
// hand or a partial body would go out labelled 200 and the browser would treat
|
||||||
|
// it as the whole file.
|
||||||
|
const statusCode = reply.statusCode;
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
|
reply.raw.statusCode = statusCode;
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(headers)) {
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
@@ -106,12 +113,54 @@ function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
|||||||
content.pipe(reply.raw);
|
content.pipe(reply.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a file body, honoring a `Range` request header.
|
||||||
|
*
|
||||||
|
* Callers set Content-Type/Content-Disposition first; this adds the
|
||||||
|
* range-related headers and the body. Range support is what makes the file
|
||||||
|
* viewer's `<video>`/`<audio>` seekable: with a plain 200 and no
|
||||||
|
* `Accept-Ranges`, Chrome reports `video.seekable` as `[0, 0]`, the scrub bar
|
||||||
|
* does nothing and `currentTime = x` is silently reverted (measured against an
|
||||||
|
* 18MB mp4 before this existed). It also stops each seek from re-reading the
|
||||||
|
* whole file into memory.
|
||||||
|
*/
|
||||||
|
function sendFileBody(
|
||||||
|
reply: FastifyReply,
|
||||||
|
resolvedPath: string,
|
||||||
|
size: number,
|
||||||
|
rangeHeader: string | string[] | undefined
|
||||||
|
): void {
|
||||||
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
|
const range = parseByteRange(rangeHeader, size);
|
||||||
|
|
||||||
|
if (range.kind === 'unsatisfiable') {
|
||||||
|
reply
|
||||||
|
.code(416)
|
||||||
|
.header('Content-Range', `bytes */${size}`)
|
||||||
|
.type('application/json; charset=utf-8')
|
||||||
|
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Requested range not satisfiable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.kind === 'partial') {
|
||||||
|
reply.code(206);
|
||||||
|
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
|
||||||
|
reply.header('Content-Length', range.end - range.start + 1);
|
||||||
|
sendRawStream(reply, createReadStream(resolvedPath, { start: range.start, end: range.end }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Length', size);
|
||||||
|
sendRawStream(reply, createReadStream(resolvedPath));
|
||||||
|
}
|
||||||
|
|
||||||
async function serveRawFile(
|
async function serveRawFile(
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
resolvedPath: string,
|
resolvedPath: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
extension: string,
|
extension: string,
|
||||||
download?: boolean
|
download?: boolean,
|
||||||
|
rangeHeader?: string | string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stat = await fs.stat(resolvedPath);
|
const stat = await fs.stat(resolvedPath);
|
||||||
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
||||||
@@ -126,24 +175,21 @@ async function serveRawFile(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = createReadStream(resolvedPath);
|
|
||||||
if (download || extension === 'svg') {
|
if (download || extension === 'svg') {
|
||||||
reply.header(
|
reply.header(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||||
);
|
);
|
||||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||||
reply.header('Content-Length', stat.size);
|
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
sendRawStream(reply, content);
|
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||||
reply.header('Content-Length', stat.size);
|
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
sendRawStream(reply, content);
|
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttachmentOr404(
|
function getAttachmentOr404(
|
||||||
@@ -849,7 +895,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await serveRawFile(reply, resolvedPath, fileName, extension);
|
await serveRawFile(reply, resolvedPath, fileName, extension, false, req.headers.range);
|
||||||
});
|
});
|
||||||
|
|
||||||
// File tree listing
|
// File tree listing
|
||||||
@@ -1369,24 +1415,24 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
json: 'application/json',
|
json: 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = await fs.readFile(resolvedPath);
|
|
||||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||||
if (download === 'true' || ext === 'svg') {
|
if (download === 'true' || ext === 'svg') {
|
||||||
reply.raw.writeHead(200, {
|
reply.header(
|
||||||
...inheritedHeaders(reply),
|
'Content-Type',
|
||||||
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
|
ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream'
|
||||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
);
|
||||||
'Content-Length': content.length,
|
reply.header('Content-Disposition', `attachment; filename="${basename}"`);
|
||||||
'X-Content-Type-Options': 'nosniff',
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
});
|
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||||
reply.raw.end(content);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
reply.send(content);
|
// Streamed, range-aware: this is the <video>/<audio> source the file
|
||||||
|
// viewer points at, and a 200-only response makes the media unseekable.
|
||||||
|
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply
|
reply
|
||||||
.code(500)
|
.code(500)
|
||||||
@@ -1503,7 +1549,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
if (!servePath) return;
|
if (!servePath) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
|
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true', req.headers.range);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply
|
reply
|
||||||
.code(500)
|
.code(500)
|
||||||
|
|||||||
@@ -2292,6 +2292,14 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
const fullSize = rawBuffer.length;
|
const fullSize = rawBuffer.length;
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
|
// WHY the reason and not just the boolean (#258): `truncated` is set at two
|
||||||
|
// sites that mean opposite things to a user. 'tail' is an intentional
|
||||||
|
// partial replay and the rest is still retained, so a `full=1` pull recovers
|
||||||
|
// it. 'capped' means we hit the byte ceiling — and on a full-history capture
|
||||||
|
// that is already everything tmux holds, so the oldest output is genuinely
|
||||||
|
// out of reach rather than one click away. Collapsing both into one flag is
|
||||||
|
// why the UI could only ever say "truncated for performance".
|
||||||
|
let truncationReason: 'capped' | 'tail' | null = null;
|
||||||
let cleanBuffer: string;
|
let cleanBuffer: string;
|
||||||
|
|
||||||
// Cap the payload EARLY — before the regex normalization passes below run
|
// Cap the payload EARLY — before the regex normalization passes below run
|
||||||
@@ -2302,6 +2310,7 @@ export function registerSessionRoutes(
|
|||||||
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
|
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
|
||||||
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
|
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
|
||||||
truncated = true;
|
truncated = true;
|
||||||
|
truncationReason = 'capped';
|
||||||
const capNewline = rawBuffer.indexOf('\n');
|
const capNewline = rawBuffer.indexOf('\n');
|
||||||
if (capNewline > 0 && capNewline < 4096) {
|
if (capNewline > 0 && capNewline < 4096) {
|
||||||
rawBuffer = rawBuffer.slice(capNewline + 1);
|
rawBuffer = rawBuffer.slice(capNewline + 1);
|
||||||
@@ -2335,6 +2344,9 @@ export function registerSessionRoutes(
|
|||||||
// Banner is near the top and gets discarded by tail anyway.
|
// Banner is near the top and gets discarded by tail anyway.
|
||||||
cleanBuffer = strippedBuffer.slice(-tailBytes);
|
cleanBuffer = strippedBuffer.slice(-tailBytes);
|
||||||
truncated = true;
|
truncated = true;
|
||||||
|
// 'capped' already means the oldest bytes are gone for good; a tail cut on
|
||||||
|
// top of it does not soften that, so the stronger reason wins.
|
||||||
|
truncationReason ??= 'tail';
|
||||||
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
||||||
// and start from there. This prevents xterm.js from parsing a partial escape
|
// and start from there. This prevents xterm.js from parsing a partial escape
|
||||||
// sequence which corrupts cursor position for all subsequent Ink redraws.
|
// sequence which corrupts cursor position for all subsequent Ink redraws.
|
||||||
@@ -2365,6 +2377,10 @@ export function registerSessionRoutes(
|
|||||||
status: session.status,
|
status: session.status,
|
||||||
fullSize,
|
fullSize,
|
||||||
truncated,
|
truncated,
|
||||||
|
truncationReason,
|
||||||
|
// `retainedBytes` is what this response actually carries; `fullSize` is
|
||||||
|
// what existed before the cut. The gap is what the indicator reports.
|
||||||
|
retainedBytes: cleanBuffer.length,
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-1
@@ -5,8 +5,9 @@
|
|||||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||||
* Both files MUST be kept in sync.
|
* Both files MUST be kept in sync.
|
||||||
*
|
*
|
||||||
* 154 event constants organized by category:
|
* 155 event constants organized by category:
|
||||||
* - **Core** (1): init
|
* - **Core** (1): init
|
||||||
|
* - **Transport** (1): sse:heartbeat
|
||||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||||
@@ -52,6 +53,22 @@
|
|||||||
/** Sent to each SSE client on initial connection with full app state. */
|
/** Sent to each SSE client on initial connection with full app state. */
|
||||||
export const Init = 'init' as const;
|
export const Init = 'init' as const;
|
||||||
|
|
||||||
|
// ─── Transport ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liveness frame written to every SSE client every `SSE_HEARTBEAT_INTERVAL`.
|
||||||
|
* Payload: `{ t: <epoch ms> }`.
|
||||||
|
*
|
||||||
|
* Carries no application data; its only job is to be *observable*. This was a
|
||||||
|
* `:keepalive` SSE **comment**, and comments are invisible to `EventSource` by
|
||||||
|
* spec, so a stream that stopped delivering without erroring (a proxy that
|
||||||
|
* idle-closed it, a laptop resumed from sleep, a tailnet reconnect) was
|
||||||
|
* undetectable to the client: `onerror` never fires and the UI freezes until a
|
||||||
|
* reload. A named event reaches a listener, which is what lets the client's
|
||||||
|
* staleness watchdog notice the silence and force a reconnect.
|
||||||
|
*/
|
||||||
|
export const Heartbeat = 'sse:heartbeat' as const;
|
||||||
|
|
||||||
// ─── Session Lifecycle ───────────────────────────────────────────────────────
|
// ─── Session Lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** New session spawned. */
|
/** New session spawned. */
|
||||||
@@ -443,6 +460,9 @@ export const SseEvent = {
|
|||||||
// Core
|
// Core
|
||||||
Init,
|
Init,
|
||||||
|
|
||||||
|
// Transport
|
||||||
|
Heartbeat,
|
||||||
|
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
SessionCreated,
|
SessionCreated,
|
||||||
SessionUpdated,
|
SessionUpdated,
|
||||||
|
|||||||
@@ -470,12 +470,20 @@ export class SseStreamManager {
|
|||||||
// ========== Client Health ==========
|
// ========== Client Health ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up dead SSE clients and send keep-alive comments.
|
* Clean up dead SSE clients and send the liveness heartbeat.
|
||||||
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
||||||
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
||||||
|
*
|
||||||
|
* The heartbeat is a NAMED event, not the `:keepalive` comment it used to be:
|
||||||
|
* comments are invisible to `EventSource` by spec, so a stream that stopped
|
||||||
|
* delivering without erroring was undetectable to the client (see
|
||||||
|
* `SseEvent.Heartbeat`). Written per-client rather than through `broadcast()`
|
||||||
|
* deliberately: the frame carries no session data, so it needs no owner
|
||||||
|
* routing, and this loop is already walking every client to check its socket.
|
||||||
*/
|
*/
|
||||||
cleanupDeadClients(): void {
|
cleanupDeadClients(): void {
|
||||||
const deadClients: FastifyReply[] = [];
|
const deadClients: FastifyReply[] = [];
|
||||||
|
const heartbeat = `event: ${SseEvent.Heartbeat}\ndata: ${JSON.stringify({ t: Date.now() })}\n\n`;
|
||||||
|
|
||||||
for (const [client] of this.sseClients) {
|
for (const [client] of this.sseClients) {
|
||||||
try {
|
try {
|
||||||
@@ -484,11 +492,9 @@ export class SseStreamManager {
|
|||||||
if (!socket || socket.destroyed || !socket.writable) {
|
if (!socket || socket.destroyed || !socket.writable) {
|
||||||
deadClients.push(client);
|
deadClients.push(client);
|
||||||
} else {
|
} else {
|
||||||
// Send SSE comment as keep-alive. Only add padding when tunnel is
|
// Only add padding when tunnel is active: it flushes Cloudflare
|
||||||
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
|
// proxy buffers but wastes bandwidth for direct/Tailscale connections.
|
||||||
// for direct/Tailscale connections.
|
client.raw.write(this._isTunnelActive ? heartbeat + SSE_PADDING : heartbeat);
|
||||||
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
|
|
||||||
client.raw.write(ka);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Error accessing socket means client is dead
|
// Error accessing socket means client is dead
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview File viewer media teardown: closing the preview must stop the video.
|
||||||
|
*
|
||||||
|
* `closeFilePreview()` used to do nothing but drop the overlay's `visible`
|
||||||
|
* class. That hides the overlay (`display: none`) and hides it ONLY: the
|
||||||
|
* `<video>` inside carried on playing, so the audio kept going after the user
|
||||||
|
* pressed X, with no visible player to pause. Detaching the element is not a fix
|
||||||
|
* either — a detached HTMLMediaElement plays until it is garbage collected —
|
||||||
|
* which is why the teardown has to pause() and unload the element explicitly.
|
||||||
|
*
|
||||||
|
* What is pinned here:
|
||||||
|
* 1. close pauses AND unloads every media element (not just the first),
|
||||||
|
* 2. close still works with no media in the body (the common text case),
|
||||||
|
* 3. opening a NEW preview stops what the previous one was playing, since
|
||||||
|
* overwriting innerHTML only detaches it,
|
||||||
|
* 4. a dirty edit buffer still wins: cancelling the discard prompt must not
|
||||||
|
* tear the buffer down.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` against a stub app, same harness style as
|
||||||
|
* file-browser-hidden.test.ts (no jsdom).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||||
|
|
||||||
|
interface FakeMedia {
|
||||||
|
tag: 'video' | 'audio';
|
||||||
|
paused: boolean;
|
||||||
|
src: string | null;
|
||||||
|
loadCalls: number;
|
||||||
|
pause: () => void;
|
||||||
|
removeAttribute: (name: string) => void;
|
||||||
|
load: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeMedia(tag: 'video' | 'audio'): FakeMedia {
|
||||||
|
const el: FakeMedia = {
|
||||||
|
tag,
|
||||||
|
paused: false,
|
||||||
|
src: 'https://example.test/clip.mp4',
|
||||||
|
loadCalls: 0,
|
||||||
|
pause() {
|
||||||
|
el.paused = true;
|
||||||
|
},
|
||||||
|
removeAttribute(name: string) {
|
||||||
|
if (name === 'src') el.src = null;
|
||||||
|
},
|
||||||
|
load() {
|
||||||
|
el.loadCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadApp(media: FakeMedia[]) {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
console: { ...console, warn: vi.fn() },
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||||
|
escapeHtml: (s: string) => String(s),
|
||||||
|
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||||
|
window: { addEventListener: vi.fn() },
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
confirm: () => true,
|
||||||
|
fetch: () => {
|
||||||
|
throw new Error('fetch not stubbed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
innerHTML: '<video src="/api/sessions/s1/file-raw?path=clip.mp4" controls></video>',
|
||||||
|
querySelectorAll: (sel: string) => {
|
||||||
|
expect(sel).toBe('video, audio');
|
||||||
|
return media;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const overlay = {
|
||||||
|
classes: new Set<string>(['visible']),
|
||||||
|
classList: {
|
||||||
|
add: (c: string) => overlay.classes.add(c),
|
||||||
|
remove: (c: string) => overlay.classes.delete(c),
|
||||||
|
contains: (c: string) => overlay.classes.has(c),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const elements: Record<string, unknown> = { filePreviewBody: body, filePreviewOverlay: overlay };
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const app = new CodemanApp() as Record<string, any>;
|
||||||
|
app.$ = (id: string) => elements[id] ?? null;
|
||||||
|
app.filePreviewContent = 'previous content';
|
||||||
|
app.context = context;
|
||||||
|
return { app, body, overlay, context };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('file viewer media teardown', () => {
|
||||||
|
let media: FakeMedia[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
media = [fakeMedia('video')];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses and unloads the video when the preview is closed', () => {
|
||||||
|
const { app, overlay, body } = loadApp(media);
|
||||||
|
|
||||||
|
app.closeFilePreview();
|
||||||
|
|
||||||
|
expect(overlay.classList.contains('visible')).toBe(false);
|
||||||
|
expect(media[0].paused).toBe(true);
|
||||||
|
// src dropped + load() is what aborts the in-flight fetch; pause() alone
|
||||||
|
// leaves the browser downloading the rest of the file.
|
||||||
|
expect(media[0].src).toBeNull();
|
||||||
|
expect(media[0].loadCalls).toBe(1);
|
||||||
|
expect(body.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops every media element, not just the first', () => {
|
||||||
|
media = [fakeMedia('video'), fakeMedia('audio')];
|
||||||
|
const { app } = loadApp(media);
|
||||||
|
|
||||||
|
app.closeFilePreview();
|
||||||
|
|
||||||
|
expect(media.every((m) => m.paused && m.src === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes cleanly when the preview holds no media (the text case)', () => {
|
||||||
|
const { app, overlay } = loadApp([]);
|
||||||
|
|
||||||
|
expect(() => app.closeFilePreview()).not.toThrow();
|
||||||
|
expect(overlay.classList.contains('visible')).toBe(false);
|
||||||
|
expect(app.filePreviewContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a media element that throws on teardown', () => {
|
||||||
|
const hostile = fakeMedia('video');
|
||||||
|
hostile.pause = () => {
|
||||||
|
throw new Error('detached');
|
||||||
|
};
|
||||||
|
const { app, overlay } = loadApp([hostile]);
|
||||||
|
|
||||||
|
expect(() => app.closeFilePreview()).not.toThrow();
|
||||||
|
expect(overlay.classList.contains('visible')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the previous video when another file is previewed', async () => {
|
||||||
|
const { app, context } = loadApp(media);
|
||||||
|
// openFilePreview bails right after the teardown: the fetch stub rejects and
|
||||||
|
// the handler swallows it, which is enough to pin the teardown ordering.
|
||||||
|
context.fetch = async () => ({ ok: false, json: async () => ({ success: false }) });
|
||||||
|
app._resetFilePreviewEdit = () => {};
|
||||||
|
app.$ = ((orig) => (id: string) => (id === 'filePreviewTitle' || id === 'filePreviewFooter' ? {} : orig(id)))(
|
||||||
|
app.$
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.openFilePreview('other.txt', 's1');
|
||||||
|
|
||||||
|
expect(media[0].paused).toBe(true);
|
||||||
|
expect(media[0].src).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the editor buffer when the discard prompt is declined', () => {
|
||||||
|
const { app, overlay, context } = loadApp(media);
|
||||||
|
context.confirm = () => false;
|
||||||
|
app.filePreviewEdit = { dirty: true };
|
||||||
|
|
||||||
|
app.closeFilePreview();
|
||||||
|
|
||||||
|
expect(overlay.classList.contains('visible')).toBe(true);
|
||||||
|
expect(media[0].paused).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Port: none (pure helpers from constants.js in a vm context).
|
||||||
|
//
|
||||||
|
// Issue #258: terminal history is split across browser scrollback, the server
|
||||||
|
// byte buffer and tmux, and the only signal the user got was a grey line written
|
||||||
|
// INTO the terminal saying "earlier output truncated for performance". That line
|
||||||
|
// scrolls away with the output it describes, cannot be acted on, and says the
|
||||||
|
// same thing whether the rest is one click away or gone forever.
|
||||||
|
//
|
||||||
|
// computeHistoryTruncationNotice() is the pure core of the replacement banner.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
|
||||||
|
function loadHelpers() {
|
||||||
|
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
|
||||||
|
vm.runInContext(
|
||||||
|
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
|
||||||
|
;globalThis.__helpers = { formatHistoryBytes, computeHistoryTruncationNotice };`,
|
||||||
|
context,
|
||||||
|
{ filename: 'constants.js' }
|
||||||
|
);
|
||||||
|
return (context as any).__helpers as {
|
||||||
|
formatHistoryBytes: (n: number) => string;
|
||||||
|
computeHistoryTruncationNotice: (s: Record<string, unknown>) => {
|
||||||
|
visible: boolean;
|
||||||
|
message: string;
|
||||||
|
canLoadMore: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatHistoryBytes', () => {
|
||||||
|
const { formatHistoryBytes } = loadHelpers();
|
||||||
|
|
||||||
|
it('reports sub-KB amounts as a range, not a byte count', () => {
|
||||||
|
expect(formatHistoryBytes(400)).toBe('less than 1 KB');
|
||||||
|
expect(formatHistoryBytes(0)).toBe('less than 1 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales to KB and MB', () => {
|
||||||
|
expect(formatHistoryBytes(2048)).toBe('2 KB');
|
||||||
|
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives junk input rather than printing NaN into the UI', () => {
|
||||||
|
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
|
||||||
|
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
|
||||||
|
expect(formatHistoryBytes(undefined as unknown as number)).toBe('less than 1 KB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeHistoryTruncationNotice (issue #258)', () => {
|
||||||
|
const { computeHistoryTruncationNotice } = loadHelpers();
|
||||||
|
|
||||||
|
it('stays hidden when the replay was complete', () => {
|
||||||
|
const notice = computeHistoryTruncationNotice({ truncated: false, fullSize: 100, retainedBytes: 100 });
|
||||||
|
expect(notice.visible).toBe(false);
|
||||||
|
expect(notice.canLoadMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers to load more after an intentional tail replay', () => {
|
||||||
|
const notice = computeHistoryTruncationNotice({
|
||||||
|
truncated: true,
|
||||||
|
reason: 'tail',
|
||||||
|
source: 'history',
|
||||||
|
fullSize: 5 * 1024 * 1024,
|
||||||
|
retainedBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(notice.visible).toBe(true);
|
||||||
|
expect(notice.canLoadMore).toBe(true);
|
||||||
|
expect(notice.message).toContain('1.0 MB');
|
||||||
|
expect(notice.message).toContain('more may still be retained');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
|
||||||
|
// This is the case the old boolean could not express: a full-history pull
|
||||||
|
// that was still capped means tmux has already given everything it has.
|
||||||
|
const notice = computeHistoryTruncationNotice({
|
||||||
|
truncated: true,
|
||||||
|
reason: 'capped',
|
||||||
|
source: 'mux-full-history',
|
||||||
|
fullSize: 40 * 1024 * 1024,
|
||||||
|
retainedBytes: 2 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(notice.visible).toBe(true);
|
||||||
|
expect(notice.canLoadMore).toBe(false);
|
||||||
|
expect(notice.message).toContain('cannot be recovered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports exhaustion when a full pull was refused as a downgrade', () => {
|
||||||
|
// _replayWouldShrinkBuffer refused: the browser holds MORE than tmux can
|
||||||
|
// return (a repaint-mode pane keeps no history), so offering "load more"
|
||||||
|
// would be offering to destroy history.
|
||||||
|
const notice = computeHistoryTruncationNotice({
|
||||||
|
truncated: true,
|
||||||
|
reason: 'tail',
|
||||||
|
source: 'history',
|
||||||
|
fullSize: 900000,
|
||||||
|
retainedBytes: 500000,
|
||||||
|
exhausted: true,
|
||||||
|
});
|
||||||
|
expect(notice.visible).toBe(true);
|
||||||
|
expect(notice.canLoadMore).toBe(false);
|
||||||
|
expect(notice.message).toContain('no longer kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets exhaustion outrank a would-be recoverable state', () => {
|
||||||
|
const recoverable = { truncated: true, reason: 'tail', source: 'history', fullSize: 900, retainedBytes: 100 };
|
||||||
|
expect(computeHistoryTruncationNotice(recoverable).canLoadMore).toBe(true);
|
||||||
|
expect(computeHistoryTruncationNotice({ ...recoverable, exhausted: true }).canLoadMore).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||||
|
it('no longer writes the notice into terminal output', () => {
|
||||||
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
// The whole point of #258 is that this notice is no longer part of the
|
||||||
|
// scrollback it describes.
|
||||||
|
expect(app).not.toContain('earlier output truncated for performance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the banner through textContent, never innerHTML', () => {
|
||||||
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
const start = app.indexOf('_renderHistoryTruncationBanner() {');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const body = app.slice(start, app.indexOf('\n _shouldFocusTerminalForTabSwitch', start));
|
||||||
|
expect(body).not.toContain('innerHTML');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Byte-range parsing for the raw file-serving routes.
|
||||||
|
*
|
||||||
|
* The file viewer's video player is only seekable when file-raw answers `Range`
|
||||||
|
* requests with 206 (measured before the fix: `video.seekable` was `[0, 0]` and
|
||||||
|
* `currentTime = x` silently reverted). What that correctness rests on is this
|
||||||
|
* parser, so the cases pinned here are the ones a media element actually emits
|
||||||
|
* plus the malformed input a browser never sends but a client can:
|
||||||
|
*
|
||||||
|
* - `bytes=0-` — how Chrome opens EVERY media element. Must be 206, not 200.
|
||||||
|
* - `bytes=-N` — the SUFFIX form (last N bytes), not "from N onwards"; mp4
|
||||||
|
* players use it to read a trailing moov atom.
|
||||||
|
* - out of bounds -> 416, malformed -> ignored (200), which are different
|
||||||
|
* answers for what looks like the same "bad range".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseByteRange } from '../src/web/http-range.js';
|
||||||
|
|
||||||
|
describe('parseByteRange', () => {
|
||||||
|
it('serves the full file when there is no Range header', () => {
|
||||||
|
expect(parseByteRange(undefined, 1000)).toEqual({ kind: 'full' });
|
||||||
|
expect(parseByteRange('', 1000)).toEqual({ kind: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers bytes=0- with a partial range (the form Chrome opens media with)', () => {
|
||||||
|
expect(parseByteRange('bytes=0-', 1000)).toEqual({ kind: 'partial', start: 0, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a closed range inclusive of both ends', () => {
|
||||||
|
expect(parseByteRange('bytes=100-199', 1000)).toEqual({ kind: 'partial', start: 100, end: 199 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an end past EOF instead of rejecting the range', () => {
|
||||||
|
expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ kind: 'partial', start: 900, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads bytes=-N as the LAST N bytes, not as an offset', () => {
|
||||||
|
expect(parseByteRange('bytes=-100', 1000)).toEqual({ kind: 'partial', start: 900, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a suffix longer than the file to the whole file', () => {
|
||||||
|
expect(parseByteRange('bytes=-5000', 1000)).toEqual({ kind: 'partial', start: 0, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a single-byte range', () => {
|
||||||
|
expect(parseByteRange('bytes=0-0', 1000)).toEqual({ kind: 'partial', start: 0, end: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates whitespace and a capitalised unit', () => {
|
||||||
|
expect(parseByteRange(' BYTES = 10-20 ', 1000)).toEqual({ kind: 'partial', start: 10, end: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a start at or past EOF as unsatisfiable (416)', () => {
|
||||||
|
expect(parseByteRange('bytes=1000-', 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||||
|
expect(parseByteRange('bytes=1500-1600', 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a zero-length suffix as unsatisfiable', () => {
|
||||||
|
expect(parseByteRange('bytes=-0', 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports any range against an empty file as unsatisfiable', () => {
|
||||||
|
expect(parseByteRange('bytes=0-', 0)).toEqual({ kind: 'unsatisfiable' });
|
||||||
|
expect(parseByteRange('bytes=-10', 0)).toEqual({ kind: 'unsatisfiable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an inverted range rather than 416-ing it (invalid spec, not unsatisfiable)', () => {
|
||||||
|
expect(parseByteRange('bytes=500-100', 1000)).toEqual({ kind: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores units it does not implement', () => {
|
||||||
|
expect(parseByteRange('items=0-10', 1000)).toEqual({ kind: 'full' });
|
||||||
|
expect(parseByteRange('bytes 0-10', 1000)).toEqual({ kind: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores multi-range requests instead of answering only the first range', () => {
|
||||||
|
// A multipart/byteranges body is the only correct answer to these, and no
|
||||||
|
// media element asks for one — serving the whole file is spec-legal.
|
||||||
|
expect(parseByteRange('bytes=0-99,200-299', 1000)).toEqual({ kind: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores malformed specs', () => {
|
||||||
|
expect(parseByteRange('bytes=', 1000)).toEqual({ kind: 'full' });
|
||||||
|
expect(parseByteRange('bytes=-', 1000)).toEqual({ kind: 'full' });
|
||||||
|
expect(parseByteRange('bytes=abc-def', 1000)).toEqual({ kind: 'full' });
|
||||||
|
expect(parseByteRange('bytes=1.5-2', 1000)).toEqual({ kind: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a duplicated Range header rather than guessing which one won', () => {
|
||||||
|
expect(parseByteRange(['bytes=0-10', 'bytes=20-30'], 1000)).toEqual({ kind: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds an absurdly long offset instead of producing Infinity', () => {
|
||||||
|
// A 100-digit first-byte-pos must not reach createReadStream as Infinity.
|
||||||
|
const huge = '9'.repeat(100);
|
||||||
|
expect(parseByteRange(`bytes=${huge}-`, 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||||
|
const range = parseByteRange(`bytes=0-${huge}`, 1000);
|
||||||
|
expect(range).toEqual({ kind: 'partial', start: 0, end: 999 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -358,6 +358,83 @@ describe('Inline rename input', () => {
|
|||||||
expect(result.editingAfter).toBe(null);
|
expect(result.editingAfter).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Commit writes the confirmed name into app.sessions WITHOUT any session:updated frame', async () => {
|
||||||
|
await resetState();
|
||||||
|
expect(await startRename('no-sse', 'w9-case')).toBe(true);
|
||||||
|
|
||||||
|
// finishRename() re-renders the tab strip from app.sessions, so the rename
|
||||||
|
// used to depend on the session:updated SSE frame to carry its own write
|
||||||
|
// back. On a page whose stream has gone quiet without erroring, the PUT
|
||||||
|
// stored the new name, the re-render repainted the stale one, and the tab
|
||||||
|
// only showed it after a full reload. No SSE is dispatched here at all.
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const app = (
|
||||||
|
window as unknown as {
|
||||||
|
app: { sessions: Map<string, { id: string; name: string }> };
|
||||||
|
}
|
||||||
|
).app;
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = (async () =>
|
||||||
|
new Response('{"success":true,"data":{"name":"w9-case: fresh"}}', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})) as typeof window.fetch;
|
||||||
|
|
||||||
|
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||||
|
inputEl.value = 'fresh';
|
||||||
|
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
window.fetch = origFetch;
|
||||||
|
return { mapName: app.sessions.get('no-sse')?.name ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mapName).toBe('w9-case: fresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A rejected rename restores the old label and leaves app.sessions untouched', async () => {
|
||||||
|
await resetState();
|
||||||
|
expect(await startRename('rename-500', 'w9-case')).toBe(true);
|
||||||
|
|
||||||
|
// _apiPut turns a network error into a null Response and an API-level
|
||||||
|
// failure arrives as a non-ok status, neither of which throws, so a
|
||||||
|
// rejected rename has to be detected from the response, or it reports
|
||||||
|
// success and silently discards the user's edit.
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const app = (
|
||||||
|
window as unknown as {
|
||||||
|
app: { sessions: Map<string, { id: string; name: string }>; showToast: (m: string, k: string) => void };
|
||||||
|
}
|
||||||
|
).app;
|
||||||
|
const toasts: string[] = [];
|
||||||
|
const origToast = app.showToast;
|
||||||
|
app.showToast = (msg: string) => void toasts.push(msg);
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = (async () =>
|
||||||
|
new Response('{"success":false,"error":"boom","errorCode":"INTERNAL"}', {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})) as typeof window.fetch;
|
||||||
|
|
||||||
|
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||||
|
inputEl.value = 'never-stored';
|
||||||
|
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
window.fetch = origFetch;
|
||||||
|
app.showToast = origToast;
|
||||||
|
return {
|
||||||
|
mapName: app.sessions.get('rename-500')?.name ?? null,
|
||||||
|
label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null,
|
||||||
|
toasts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mapName).toBe('w9-case');
|
||||||
|
expect(result.label).toBe('w9-case');
|
||||||
|
expect(result.toasts).toContain('Failed to rename');
|
||||||
|
});
|
||||||
|
|
||||||
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
|
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
|
||||||
await resetState();
|
await resetState();
|
||||||
expect(await startRename('first-id', 'First')).toBe(true);
|
expect(await startRename('first-id', 'First')).toBe(true);
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
bottomRestores++;
|
bottomRestores++;
|
||||||
};
|
};
|
||||||
|
|
||||||
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
|
KeyboardHandler._scheduleViewportSettle({ restoreScroll: true });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
KeyboardHandler._scheduleViewportSettle();
|
KeyboardHandler._scheduleViewportSettle();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
@@ -446,7 +446,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
|
|
||||||
// A real transition arms the work; a following wiggle defers it but the
|
// A real transition arms the work; a following wiggle defers it but the
|
||||||
// settle still fires exactly once.
|
// settle still fires exactly once.
|
||||||
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
|
KeyboardHandler._scheduleViewportSettle({ restoreScroll: true });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
KeyboardHandler._deferViewportSettle();
|
KeyboardHandler._deferViewportSettle();
|
||||||
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
|
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Range-request coverage for the raw file-serving routes.
|
||||||
|
*
|
||||||
|
* The file viewer points a `<video>` at `GET /api/sessions/:id/file-raw`. That
|
||||||
|
* route used to read the whole file and answer 200 with no `Accept-Ranges`,
|
||||||
|
* which makes a browser treat the media as unseekable: measured against an 18MB
|
||||||
|
* mp4, `video.seekable` was `[0, 0]` and assigning `currentTime` was reverted on
|
||||||
|
* the next tick, so the scrub bar looked dead.
|
||||||
|
*
|
||||||
|
* These tests pin the wire contract that makes seeking work, since none of it is
|
||||||
|
* visible from a plain 200-vs-404 assertion:
|
||||||
|
* 1. `Accept-Ranges: bytes` on the un-ranged response (what tells the browser
|
||||||
|
* it MAY seek at all),
|
||||||
|
* 2. 206 + `Content-Range` + the sliced body for a range request,
|
||||||
|
* 3. the slice actually coming from a bounded read, not a full-file read that
|
||||||
|
* is then truncated,
|
||||||
|
* 4. 416 (with `Content-Range: bytes */size`) for a range past EOF, rather
|
||||||
|
* than a silent full-body 200 the media element cannot interpret.
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real ports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
|
||||||
|
const FILE_BYTES = Buffer.from('0123456789ABCDEFGHIJ'); // 20 bytes, index == value position
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
readFile: vi.fn(async () => Buffer.from('unused')),
|
||||||
|
stat: vi.fn(async () => ({ size: 20, isFile: () => true, isDirectory: () => false, mtimeMs: 1 })),
|
||||||
|
readdir: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
realpathSync: vi.fn((p: string) => p),
|
||||||
|
// Honour start/end so a test can tell a real bounded read from a full read.
|
||||||
|
createReadStream: vi.fn((_path: string, opts?: { start?: number; end?: number }) => {
|
||||||
|
const start = opts?.start ?? 0;
|
||||||
|
const end = opts?.end ?? FILE_BYTES.length - 1;
|
||||||
|
return Readable.from([FILE_BYTES.subarray(start, end + 1)]);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/file-stream-manager.js', () => ({
|
||||||
|
fileStreamManager: {
|
||||||
|
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
|
||||||
|
closeStream: vi.fn(() => true),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { createReadStream, realpathSync } from 'node:fs';
|
||||||
|
|
||||||
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
|
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||||
|
const mockedCreateReadStream = vi.mocked(createReadStream);
|
||||||
|
|
||||||
|
describe('file-raw range requests', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
let sid: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerFileRoutes);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: FILE_BYTES.length, isFile: () => true } as never);
|
||||||
|
mockedCreateReadStream.mockImplementation(
|
||||||
|
(_path: unknown, opts?: unknown) =>
|
||||||
|
Readable.from([
|
||||||
|
FILE_BYTES.subarray(
|
||||||
|
(opts as { start?: number })?.start ?? 0,
|
||||||
|
((opts as { end?: number })?.end ?? FILE_BYTES.length - 1) + 1
|
||||||
|
),
|
||||||
|
]) as never
|
||||||
|
);
|
||||||
|
sid = harness.ctx._sessionId as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawUrl = (name = 'clip.mp4') => `/api/sessions/${sid}/file-raw?path=${name}`;
|
||||||
|
|
||||||
|
it('advertises Accept-Ranges on an un-ranged response, so the browser knows it may seek', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: rawUrl() });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||||
|
expect(res.headers['content-type']).toBe('video/mp4');
|
||||||
|
expect(res.headers['content-length']).toBe(String(FILE_BYTES.length));
|
||||||
|
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers bytes=0- with 206 (Chrome opens every media element this way)', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl(),
|
||||||
|
headers: { range: 'bytes=0-' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe(`bytes 0-19/${FILE_BYTES.length}`);
|
||||||
|
expect(res.headers['content-length']).toBe(String(FILE_BYTES.length));
|
||||||
|
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a mid-file slice from a bounded read', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl(),
|
||||||
|
headers: { range: 'bytes=5-9' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 5-9/20');
|
||||||
|
expect(res.headers['content-length']).toBe('5');
|
||||||
|
expect(res.rawPayload.toString()).toBe('56789');
|
||||||
|
// The read itself must be bounded: a full read that is sliced afterwards
|
||||||
|
// would still pull an 18MB video into memory on every seek.
|
||||||
|
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.any(String), { start: 5, end: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a suffix range as the LAST N bytes', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl(),
|
||||||
|
headers: { range: 'bytes=-4' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 16-19/20');
|
||||||
|
expect(res.rawPayload.toString()).toBe('GHIJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a range past EOF with 416 instead of a full-body 200', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl(),
|
||||||
|
headers: { range: 'bytes=100-200' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(416);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes */20');
|
||||||
|
expect(JSON.parse(res.body).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a malformed range and serves the whole file', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl(),
|
||||||
|
headers: { range: 'bytes=abc-def' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the security headers on a partial response', async () => {
|
||||||
|
// 206 bodies go out through reply.hijack(), which bypasses Fastify's own
|
||||||
|
// header write — the nosniff/type headers have to be carried across by hand.
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl(),
|
||||||
|
headers: { range: 'bytes=0-3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(res.headers['content-type']).toBe('video/mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports resuming a download (?download=true) as well as inline playback', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${rawUrl('clip.mp4')}&download=true`,
|
||||||
|
headers: { range: 'bytes=10-14' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-disposition']).toContain('attachment; filename="clip.mp4"');
|
||||||
|
expect(res.rawPayload.toString()).toBe('ABCDE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses files past the raw size cap before looking at Range', async () => {
|
||||||
|
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: rawUrl('huge.mp4'),
|
||||||
|
headers: { range: 'bytes=0-99' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
import { ApiErrorCode } from '../../src/types.js';
|
import { ApiErrorCode } from '../../src/types.js';
|
||||||
@@ -19,12 +20,15 @@ vi.mock('node:fs/promises', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock realpathSync for symlink resolution
|
// Mock realpathSync for symlink resolution, plus createReadStream: file-raw
|
||||||
|
// STREAMS its body (range support), so an unmocked read would hit the real
|
||||||
|
// filesystem and fail with ENOENT rather than serving the fixture bytes.
|
||||||
vi.mock('node:fs', async (importOriginal) => {
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('node:fs')>();
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
realpathSync: vi.fn((p: string) => p),
|
realpathSync: vi.fn((p: string) => p),
|
||||||
|
createReadStream: vi.fn(() => Readable.from([Buffer.from('fake file bytes')])),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,13 +41,14 @@ vi.mock('../../src/file-stream-manager.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { realpathSync } from 'node:fs';
|
import { createReadStream, realpathSync } from 'node:fs';
|
||||||
import { fileStreamManager } from '../../src/file-stream-manager.js';
|
import { fileStreamManager } from '../../src/file-stream-manager.js';
|
||||||
|
|
||||||
const mockedReaddir = vi.mocked(fs.readdir);
|
const mockedReaddir = vi.mocked(fs.readdir);
|
||||||
const mockedReadFile = vi.mocked(fs.readFile);
|
const mockedReadFile = vi.mocked(fs.readFile);
|
||||||
const mockedStat = vi.mocked(fs.stat);
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
const mockedRealpathSync = vi.mocked(realpathSync);
|
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||||
|
const mockedCreateReadStream = vi.mocked(createReadStream);
|
||||||
const mockedFileStreamManager = vi.mocked(fileStreamManager);
|
const mockedFileStreamManager = vi.mocked(fileStreamManager);
|
||||||
|
|
||||||
describe('file-routes', () => {
|
describe('file-routes', () => {
|
||||||
@@ -55,6 +60,7 @@ describe('file-routes', () => {
|
|||||||
|
|
||||||
// Default: realpathSync returns the path unchanged
|
// Default: realpathSync returns the path unchanged
|
||||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
|
mockedCreateReadStream.mockImplementation(() => Readable.from([Buffer.from('fake file bytes')]) as never);
|
||||||
// Default stat
|
// Default stat
|
||||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||||
mockedReadFile.mockImplementation(async (path) =>
|
mockedReadFile.mockImplementation(async (path) =>
|
||||||
@@ -739,7 +745,7 @@ describe('file-routes', () => {
|
|||||||
|
|
||||||
it('serves raw file with correct content type', async () => {
|
it('serves raw file with correct content type', async () => {
|
||||||
const content = Buffer.from('fake png data');
|
const content = Buffer.from('fake png data');
|
||||||
mockedReadFile.mockResolvedValue(content as never);
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||||
|
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
@@ -752,7 +758,7 @@ describe('file-routes', () => {
|
|||||||
|
|
||||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||||
mockedReadFile.mockResolvedValue(content as never);
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||||
|
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||||
|
|
||||||
interface LocalHarness {
|
interface LocalHarness {
|
||||||
app: FastifyInstance;
|
app: FastifyInstance;
|
||||||
@@ -632,6 +633,77 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer).toBeDefined();
|
expect(body.data.terminalBuffer).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── #258: a single `truncated` boolean could not distinguish "we tailed for
|
||||||
|
// speed, the rest is still there" from "the oldest bytes are gone". The UI
|
||||||
|
// needs that difference to know whether offering "Load full history" is a
|
||||||
|
// promise it can keep.
|
||||||
|
describe('truncation reason (#258)', () => {
|
||||||
|
const lines = (n: number) => Array.from({ length: n }, (_, i) => `history line ${i}`).join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
|
||||||
|
harness.ctx._session.mode = 'shell';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no reason when nothing was cut', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = 'short buffer';
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.truncated).toBe(false);
|
||||||
|
expect(body.data.truncationReason).toBeNull();
|
||||||
|
expect(body.data.retainedBytes).toBe(body.data.terminalBuffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'tail' for an intentional partial replay", async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = lines(4000);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=500`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.truncated).toBe(true);
|
||||||
|
expect(body.data.truncationReason).toBe('tail');
|
||||||
|
// fullSize describes what existed, retainedBytes what was sent.
|
||||||
|
expect(body.data.retainedBytes).toBeLessThan(body.data.fullSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'capped' when the byte ceiling dropped the oldest output", async () => {
|
||||||
|
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
|
||||||
|
...resolveTerminalHistoryConfig({}),
|
||||||
|
terminalBufferMaxBytes: 2000,
|
||||||
|
}));
|
||||||
|
harness.ctx._session.terminalBuffer = lines(4000);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.truncated).toBe(true);
|
||||||
|
expect(body.data.truncationReason).toBe('capped');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 'capped' when a tail cut lands on top of it", async () => {
|
||||||
|
// Both sites fire. 'capped' is the stronger statement (bytes are gone),
|
||||||
|
// so a subsequent tail must not downgrade it to the recoverable reason.
|
||||||
|
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
|
||||||
|
...resolveTerminalHistoryConfig({}),
|
||||||
|
terminalBufferMaxBytes: 2000,
|
||||||
|
}));
|
||||||
|
harness.ctx._session.terminalBuffer = lines(4000);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=500`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.truncationReason).toBe('capped');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
|
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
|
||||||
const shellHistory = Array.from(
|
const shellHistory = Array.from(
|
||||||
{ length: 3000 },
|
{ length: 3000 },
|
||||||
|
|||||||
@@ -77,25 +77,47 @@ describe('lineage line geometry', () => {
|
|||||||
expect(first.d).not.toBe(second.d);
|
expect(first.d).not.toBe(second.d);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to a vertical bezier when the strip has wrapped to two rows', () => {
|
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
||||||
const helper = loadLineageHelper();
|
const helper = loadLineageHelper();
|
||||||
|
// A worker the agent skill starts is appended to the END of the strip, so this
|
||||||
|
// is the span the feature is actually used at. The first shipped clamp (44px)
|
||||||
|
// turned it into a flat thread across the terminal.
|
||||||
|
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
||||||
|
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||||
|
|
||||||
|
const wideDip = controlYs(wide.d)[0] - 34;
|
||||||
|
const nearDip = controlYs(near.d)[0] - 34;
|
||||||
|
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
||||||
|
expect(wideDip).toBeGreaterThanOrEqual(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
||||||
|
const helper = loadLineageHelper();
|
||||||
|
// The reported bug: with the desktop strip wrapped, a parent on row 1 (bottom 34)
|
||||||
|
// and its child on row 2 (top 48) are 14px apart, and a parent-bottom → child-TOP
|
||||||
|
// bezier had 14px to bend in, so it drew a flat line hidden in the gap, three
|
||||||
|
// siblings overprinting each other. Both ends now anchor on the tab BOTTOM and the
|
||||||
|
// curve hangs below the LOWER row, the same bracket the flat strip gets.
|
||||||
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
||||||
const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!;
|
const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!;
|
||||||
|
|
||||||
expect(geom.sameRow).toBe(false);
|
expect(geom.sameRow).toBe(false);
|
||||||
// Parent bottom (34) → child top (48): the arc travels between rows.
|
expect(geom.d.startsWith('M 60 34')).toBe(true); // parent BOTTOM
|
||||||
expect(geom.d.startsWith('M 60 34')).toBe(true);
|
expect(geom.endY).toBe(78); // child BOTTOM, not its top
|
||||||
expect(geom.endY).toBe(48);
|
// Every control point clears the lower row by at least the minimum dip.
|
||||||
|
for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws upward when the child sits on the row ABOVE its parent', () => {
|
it('draws the same bracket when the child sits on the row ABOVE its parent', () => {
|
||||||
const helper = loadLineageHelper();
|
const helper = loadLineageHelper();
|
||||||
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
||||||
const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!;
|
const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!;
|
||||||
|
|
||||||
expect(geom.sameRow).toBe(false);
|
expect(geom.sameRow).toBe(false);
|
||||||
expect(geom.d.startsWith('M 60 48')).toBe(true); // parent TOP edge
|
expect(geom.d.startsWith('M 60 78')).toBe(true); // parent BOTTOM
|
||||||
expect(geom.endY).toBe(34); // child bottom edge
|
expect(geom.endY).toBe(34); // child BOTTOM
|
||||||
|
// The parent's row is the lower one here, so that is what the curve clears.
|
||||||
|
for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips an edge whose tab is scrolled out of the strip', () => {
|
it('skips an edge whose tab is scrolled out of the strip', () => {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* SSE liveness heartbeat.
|
||||||
|
*
|
||||||
|
* `cleanupDeadClients()` runs every SSE_HEARTBEAT_INTERVAL (15s) and does two
|
||||||
|
* jobs: evict clients whose socket died, and write a liveness frame to the
|
||||||
|
* ones that are still up.
|
||||||
|
*
|
||||||
|
* The regression these guard: that frame used to be an SSE `:keepalive`
|
||||||
|
* COMMENT, and comments are invisible to `EventSource` by spec. A stream that
|
||||||
|
* stopped delivering without erroring was therefore undetectable to the
|
||||||
|
* client: `onerror` never fired, the header dot stayed green, and every
|
||||||
|
* SSE-driven surface froze until the user reloaded. A named `sse:heartbeat`
|
||||||
|
* event reaches a listener, which is what lets the client's staleness
|
||||||
|
* watchdog notice the silence (see test/sse-staleness.test.ts).
|
||||||
|
*
|
||||||
|
* No port needed (the manager is driven directly with fake replies).
|
||||||
|
*/
|
||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SSE_PADDING_SIZE } from '../src/config/server-timing.js';
|
||||||
|
import { SseEvent } from '../src/web/sse-events.js';
|
||||||
|
import { SseStreamManager } from '../src/web/sse-stream-manager.js';
|
||||||
|
import { CleanupManager } from '../src/utils/index.js';
|
||||||
|
|
||||||
|
/** A FastifyReply stand-in that records every raw write. */
|
||||||
|
function fakeClient(opts: { destroyed?: boolean; writable?: boolean; throwOnAccess?: boolean } = {}) {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const socket = { destroyed: opts.destroyed ?? false, writable: opts.writable ?? true };
|
||||||
|
const raw = {
|
||||||
|
get socket() {
|
||||||
|
if (opts.throwOnAccess) throw new Error('socket gone');
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
write(chunk: string) {
|
||||||
|
writes.push(chunk);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { reply: { raw } as unknown as FastifyReply, writes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeManager() {
|
||||||
|
const cleanup = new CleanupManager();
|
||||||
|
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||||
|
return { manager, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SSE liveness heartbeat', () => {
|
||||||
|
it('writes a NAMED sse:heartbeat event, not an invisible comment', () => {
|
||||||
|
const { manager, cleanup } = makeManager();
|
||||||
|
const client = fakeClient();
|
||||||
|
manager.addClient(client.reply, null, false);
|
||||||
|
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
|
||||||
|
expect(client.writes).toHaveLength(1);
|
||||||
|
const frame = client.writes[0];
|
||||||
|
// A comment (`:keepalive`) never reaches an EventSource listener, and that is
|
||||||
|
// the entire bug. The frame must be a dispatchable named event.
|
||||||
|
expect(frame.startsWith(':')).toBe(false);
|
||||||
|
expect(frame).toMatch(/^event: sse:heartbeat\n/);
|
||||||
|
expect(frame.endsWith('\n\n')).toBe(true);
|
||||||
|
expect(SseEvent.Heartbeat).toBe('sse:heartbeat');
|
||||||
|
cleanup.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a parseable epoch-ms payload', () => {
|
||||||
|
const { manager, cleanup } = makeManager();
|
||||||
|
const client = fakeClient();
|
||||||
|
manager.addClient(client.reply, null, false);
|
||||||
|
const before = Date.now();
|
||||||
|
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
|
||||||
|
const dataLine = client.writes[0].split('\n').find((l) => l.startsWith('data: '));
|
||||||
|
expect(dataLine).toBeDefined();
|
||||||
|
const payload = JSON.parse(dataLine!.slice('data: '.length)) as { t: number };
|
||||||
|
expect(payload.t).toBeGreaterThanOrEqual(before);
|
||||||
|
expect(payload.t).toBeLessThanOrEqual(Date.now());
|
||||||
|
cleanup.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still appends Cloudflare tunnel padding when a tunnel is active', () => {
|
||||||
|
const { manager, cleanup } = makeManager();
|
||||||
|
const client = fakeClient();
|
||||||
|
manager.addClient(client.reply, null, false);
|
||||||
|
manager.setTunnelActive(true);
|
||||||
|
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
|
||||||
|
const frame = client.writes[0];
|
||||||
|
expect(frame).toMatch(/^event: sse:heartbeat\n/);
|
||||||
|
// Padding rides AFTER the terminating blank line, so the event still parses.
|
||||||
|
const [event, padding] = frame.split('\n\n');
|
||||||
|
expect(event).toMatch(/^event: sse:heartbeat\ndata: \{/);
|
||||||
|
expect(padding.startsWith(':')).toBe(true);
|
||||||
|
expect(padding.length).toBeGreaterThanOrEqual(SSE_PADDING_SIZE);
|
||||||
|
cleanup.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no padding without a tunnel', () => {
|
||||||
|
const { manager, cleanup } = makeManager();
|
||||||
|
const client = fakeClient();
|
||||||
|
manager.addClient(client.reply, null, false);
|
||||||
|
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
|
||||||
|
expect(client.writes[0].length).toBeLessThan(200);
|
||||||
|
cleanup.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still evicts dead clients instead of heartbeating them', () => {
|
||||||
|
const { manager, cleanup } = makeManager();
|
||||||
|
const alive = fakeClient();
|
||||||
|
const destroyed = fakeClient({ destroyed: true });
|
||||||
|
const unwritable = fakeClient({ writable: false });
|
||||||
|
const exploding = fakeClient({ throwOnAccess: true });
|
||||||
|
for (const c of [alive, destroyed, unwritable, exploding]) manager.addClient(c.reply, null, false);
|
||||||
|
expect(manager.clientCount).toBe(4);
|
||||||
|
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
|
||||||
|
expect(manager.clientCount).toBe(1);
|
||||||
|
expect(alive.writes).toHaveLength(1);
|
||||||
|
for (const c of [destroyed, unwritable, exploding]) expect(c.writes).toHaveLength(0);
|
||||||
|
cleanup.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heartbeats every client on each pass', () => {
|
||||||
|
const { manager, cleanup } = makeManager();
|
||||||
|
const a = fakeClient();
|
||||||
|
const b = fakeClient();
|
||||||
|
manager.addClient(a.reply, null, false);
|
||||||
|
manager.addClient(b.reply, null, false);
|
||||||
|
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
manager.cleanupDeadClients();
|
||||||
|
|
||||||
|
// The frame carries no session data, so it needs no owner routing and is
|
||||||
|
// written per-client rather than through the scoped broadcast() path.
|
||||||
|
expect(a.writes).toHaveLength(2);
|
||||||
|
expect(b.writes).toHaveLength(2);
|
||||||
|
cleanup.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* SSE staleness policy.
|
||||||
|
*
|
||||||
|
* `CodemanSseStale.compute(input)` is the pure decision behind app.js's
|
||||||
|
* watchdog: given when the last SSE frame arrived, the transport status and
|
||||||
|
* the browser's online flag, it says whether the stream has gone quiet while
|
||||||
|
* still claiming to be connected: a zombie that has to be rebuilt.
|
||||||
|
*
|
||||||
|
* The regression it guards: the server's liveness keepalive used to be an SSE
|
||||||
|
* `:keepalive` COMMENT, and comments are invisible to `EventSource` by spec.
|
||||||
|
* A stream that stopped delivering without erroring (a proxy that idle-closed
|
||||||
|
* it, a laptop resumed from sleep, a tailnet reconnect) never fired `onerror`,
|
||||||
|
* so the header dot stayed green and tab status dots, sessions created on
|
||||||
|
* another device, and renames all froze until the user reloaded the page.
|
||||||
|
*
|
||||||
|
* Loaded in a plain node VM context (no jsdom), mirroring
|
||||||
|
* test/connection-loss-ui.test.ts.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type StaleInput = {
|
||||||
|
lastMessageAt?: number | null;
|
||||||
|
now?: number;
|
||||||
|
status?: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'offline';
|
||||||
|
isOnline?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadPolicy() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (
|
||||||
|
context.window as {
|
||||||
|
CodemanSseStale: { compute: (input: StaleInput) => boolean; TIMEOUT_MS: number };
|
||||||
|
}
|
||||||
|
).CodemanSseStale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T0 = 1_000_000;
|
||||||
|
|
||||||
|
describe('SSE staleness policy', () => {
|
||||||
|
it('defaults to three missed 15s heartbeats', () => {
|
||||||
|
const { TIMEOUT_MS } = loadPolicy();
|
||||||
|
expect(TIMEOUT_MS).toBe(45000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not stale while frames keep arriving', () => {
|
||||||
|
const { compute, TIMEOUT_MS } = loadPolicy();
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + TIMEOUT_MS - 1, status: 'connected' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stale once the threshold is reached', () => {
|
||||||
|
const { compute, TIMEOUT_MS } = loadPolicy();
|
||||||
|
// Boundary is inclusive: exactly three missed heartbeats already means the
|
||||||
|
// stream has been silent through a window it was contractually filling.
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + TIMEOUT_MS, status: 'connected' })).toBe(true);
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + TIMEOUT_MS * 10, status: 'connected' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a custom timeoutMs (what a browser test shrinks)', () => {
|
||||||
|
const { compute } = loadPolicy();
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + 999, status: 'connected', timeoutMs: 1000 })).toBe(false);
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + 1000, status: 'connected', timeoutMs: 1000 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never stale while the transport is already reconnecting', () => {
|
||||||
|
const { compute, TIMEOUT_MS } = loadPolicy();
|
||||||
|
// These states already have the backoff machinery running; firing on top
|
||||||
|
// of them would stack reconnects. This guard is also the loop breaker:
|
||||||
|
// a forced reconnect leaves 'connected' immediately, so the watchdog
|
||||||
|
// cannot re-fire while one is in flight.
|
||||||
|
for (const status of ['connecting', 'reconnecting', 'disconnected', 'offline'] as const) {
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + TIMEOUT_MS * 10, status })).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never stale while the device is offline', () => {
|
||||||
|
const { compute, TIMEOUT_MS } = loadPolicy();
|
||||||
|
// Nothing to reconnect to yet; the connection-loss UI already owns this.
|
||||||
|
expect(compute({ lastMessageAt: T0, now: T0 + TIMEOUT_MS * 10, status: 'connected', isOnline: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not stale before any frame has ever arrived', () => {
|
||||||
|
const { compute, TIMEOUT_MS } = loadPolicy();
|
||||||
|
// The clock starts at onopen, and `init` lands immediately after. A zero
|
||||||
|
// stamp means the stream has not opened yet, not that it went quiet. The
|
||||||
|
// constructor optimistically seeds status 'connected' before the first
|
||||||
|
// connect, so without this guard the watchdog would fire on page load.
|
||||||
|
for (const lastMessageAt of [0, null, undefined]) {
|
||||||
|
expect(compute({ lastMessageAt, now: T0 + TIMEOUT_MS * 10, status: 'connected' })).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a missing input object', () => {
|
||||||
|
const { compute } = loadPolicy();
|
||||||
|
expect(compute(undefined as unknown as StaleInput)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Port: none (pure logic in a vm context — no browser, no server).
|
||||||
|
//
|
||||||
|
// Issue #259: opening or closing the mobile keyboard forced the terminal to the
|
||||||
|
// bottom, so a user reading scrollback was yanked down to the live output. The
|
||||||
|
// settle cycle now captures scroll intent BEFORE the keyboard reflow and returns
|
||||||
|
// to that anchor instead.
|
||||||
|
//
|
||||||
|
// This lives outside test/mobile/ deliberately. That suite is Playwright-driven
|
||||||
|
// and EXCLUDED from `npm run test:ci` (config/vitest.ci.config.ts), so a
|
||||||
|
// regression guarded only there is invisible to CI — the exact blind spot that
|
||||||
|
// let the #279/#280 merge land a red mobile suite behind two green checks.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const SOURCE = readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8');
|
||||||
|
|
||||||
|
interface FakeTerminal {
|
||||||
|
buffer: { active: { viewportY: number; baseY: number } };
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
scrollToLine: (line: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load mobile-handlers.js and hand back its KeyboardHandler.
|
||||||
|
*
|
||||||
|
* The module declares `const KeyboardHandler = {...}` at top level, and a
|
||||||
|
* lexical binding does not survive to the next vm.runInContext call, so the
|
||||||
|
* export is appended to the SAME script rather than read back afterwards.
|
||||||
|
*/
|
||||||
|
function loadKeyboardHandler(opts: { viewportY: number; baseY: number }) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const terminal: FakeTerminal = {
|
||||||
|
buffer: { active: { viewportY: opts.viewportY, baseY: opts.baseY } },
|
||||||
|
scrollToBottom: () => calls.push('scrollToBottom'),
|
||||||
|
scrollToLine: (line: number) => calls.push(`scrollToLine:${line}`),
|
||||||
|
};
|
||||||
|
const app: any = {
|
||||||
|
terminal,
|
||||||
|
fitAddon: { fit: () => calls.push('fit') },
|
||||||
|
// The real predicate (terminal-ui.js isTerminalAtBottom), reproduced so the
|
||||||
|
// test exercises the same tolerance the runtime uses.
|
||||||
|
isTerminalAtBottom: () => terminal.buffer.active.viewportY >= terminal.buffer.active.baseY - 2,
|
||||||
|
relayoutMobileSubagentWindows: () => {},
|
||||||
|
};
|
||||||
|
let pendingTimer: (() => void) | null = null;
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
window: { scrollTo: () => {}, matchMedia: () => ({ matches: false }), addEventListener: () => {} },
|
||||||
|
document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} },
|
||||||
|
navigator: { userAgent: 'test', maxTouchPoints: 0 },
|
||||||
|
app,
|
||||||
|
setTimeout: (fn: () => void) => {
|
||||||
|
pendingTimer = fn;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimeout: () => {
|
||||||
|
pendingTimer = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${SOURCE}\n;globalThis.__KeyboardHandler = KeyboardHandler;`, context, {
|
||||||
|
filename: 'mobile-handlers.js',
|
||||||
|
});
|
||||||
|
const kh = (context as any).__KeyboardHandler;
|
||||||
|
// Stub the layout side effects the settle timer fires alongside the scroll.
|
||||||
|
kh._shrinkPaddingToFit = () => {};
|
||||||
|
kh._sendTerminalResize = () => {};
|
||||||
|
return {
|
||||||
|
kh,
|
||||||
|
terminal,
|
||||||
|
calls,
|
||||||
|
/** Run the coalesced settle timer the way the OS animation eventually would. */
|
||||||
|
settle: () => {
|
||||||
|
const fn = pendingTimer;
|
||||||
|
pendingTimer = null;
|
||||||
|
fn?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('keyboard settle preserves scroll intent (issue #259)', () => {
|
||||||
|
it('scrolls to bottom when the user is following live output', () => {
|
||||||
|
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 500, baseY: 500 });
|
||||||
|
|
||||||
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
settle();
|
||||||
|
|
||||||
|
expect(calls).toContain('scrollToBottom');
|
||||||
|
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the anchor instead of the bottom when the user is reading history', () => {
|
||||||
|
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
|
||||||
|
|
||||||
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
settle();
|
||||||
|
|
||||||
|
expect(calls).toContain('scrollToLine:120');
|
||||||
|
expect(calls).not.toContain('scrollToBottom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures the anchor BEFORE the reflow, not after', () => {
|
||||||
|
// The OS emits several viewport heights per animation, so the settle is
|
||||||
|
// re-scheduled repeatedly. Only the first capture predates fit(); a later
|
||||||
|
// one would read a viewportY the reflow had already moved.
|
||||||
|
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
|
||||||
|
|
||||||
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
terminal.buffer.active.viewportY = 480; // reflow drags the viewport down
|
||||||
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
settle();
|
||||||
|
|
||||||
|
expect(calls).toContain('scrollToLine:120');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an anchor that outlives the buffer it was captured from', () => {
|
||||||
|
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 400, baseY: 500 });
|
||||||
|
|
||||||
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
||||||
|
terminal.buffer.active.baseY = 90; // buffer shrank under us
|
||||||
|
settle();
|
||||||
|
|
||||||
|
expect(calls).toContain('scrollToLine:90');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the terminal alone when the settle was not a keyboard transition', () => {
|
||||||
|
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
|
||||||
|
|
||||||
|
kh._scheduleViewportSettle({});
|
||||||
|
settle();
|
||||||
|
|
||||||
|
expect(calls).toContain('fit');
|
||||||
|
expect(calls).not.toContain('scrollToBottom');
|
||||||
|
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard show/hide route through the intent-preserving path (static guard)', () => {
|
||||||
|
it('both transitions ask to restore scroll, never to force the bottom', () => {
|
||||||
|
// Slice from the METHOD DEFINITIONS ("\n name() {"), not the first
|
||||||
|
// occurrence of the name — both are called from _checkKeyboard() further up.
|
||||||
|
const bodyOf = (name: string) => {
|
||||||
|
const start = SOURCE.indexOf(`\n ${name}() {`);
|
||||||
|
expect(start, `${name} definition not found`).toBeGreaterThan(-1);
|
||||||
|
return SOURCE.slice(start, SOURCE.indexOf('\n },', start));
|
||||||
|
};
|
||||||
|
const show = bodyOf('onKeyboardShow');
|
||||||
|
const hide = bodyOf('onKeyboardHide');
|
||||||
|
|
||||||
|
expect(show).toContain('_scheduleViewportSettle({ restoreScroll: true })');
|
||||||
|
expect(hide).toContain('_scheduleViewportSettle({ restoreScroll: true })');
|
||||||
|
// The old unconditional call must not come back.
|
||||||
|
expect(SOURCE).not.toContain('scrollToBottom: true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backpressure refresh keeps a reader in place (issue #259)', () => {
|
||||||
|
// _onSessionNeedsRefresh is SERVER-triggered: it fires after SSE backpressure
|
||||||
|
// clears and rewrites the whole buffer. A user quietly reading scrollback did
|
||||||
|
// not ask for it, so being dropped to the bottom by it is the same bug as the
|
||||||
|
// keyboard yank, with no gesture to blame it on.
|
||||||
|
const loadConstants = () => {
|
||||||
|
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
|
||||||
|
vm.runInContext(
|
||||||
|
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}\n;globalThis.__fn = computeRewriteScrollLine;`,
|
||||||
|
context,
|
||||||
|
{ filename: 'constants.js' }
|
||||||
|
);
|
||||||
|
return (context as any).__fn as (i: { linesFromBottom?: number; baseY?: number }) => number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns null (scroll to bottom) for someone following live output', () => {
|
||||||
|
const computeRewriteScrollLine = loadConstants();
|
||||||
|
expect(computeRewriteScrollLine({ linesFromBottom: 0, baseY: 900 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the reader the same distance from the bottom of the NEW buffer', () => {
|
||||||
|
const computeRewriteScrollLine = loadConstants();
|
||||||
|
// The rewrite replaces the buffer, so the old absolute line is meaningless;
|
||||||
|
// 50 lines up stays 50 lines up even though baseY changed.
|
||||||
|
expect(computeRewriteScrollLine({ linesFromBottom: 50, baseY: 900 })).toBe(850);
|
||||||
|
expect(computeRewriteScrollLine({ linesFromBottom: 50, baseY: 400 })).toBe(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps when the refreshed buffer is shorter than the old offset', () => {
|
||||||
|
const computeRewriteScrollLine = loadConstants();
|
||||||
|
expect(computeRewriteScrollLine({ linesFromBottom: 900, baseY: 100 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is wired into the refresh path instead of an unconditional scrollToBottom', () => {
|
||||||
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
const start = app.indexOf('async _onSessionNeedsRefresh()');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
|
||||||
|
expect(body).toContain('computeRewriteScrollLine');
|
||||||
|
// The bottom is now one branch of a decision, never the whole story.
|
||||||
|
expect(body).toContain('this.terminal.scrollToLine(target)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers FULL history, guarded against a repaint-pane downgrade', () => {
|
||||||
|
// Measured before the fix: this path rewrote an 869-row buffer from a 1MB
|
||||||
|
// tail and left 158 rows, so the refresh meant to REPAIR the terminal was
|
||||||
|
// destroying most of its scrollback. It asks for full history now, and
|
||||||
|
// falls back to the tail only when the full capture would shrink the buffer
|
||||||
|
// (a repaint-mode pane keeps roughly one frame in tmux).
|
||||||
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
const start = app.indexOf('async _onSessionNeedsRefresh()');
|
||||||
|
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
|
||||||
|
expect(body).toContain('terminal?full=1');
|
||||||
|
expect(body).toContain('this._replayWouldShrinkBuffer(data.terminalBuffer)');
|
||||||
|
// The tail must survive as the fallback, not vanish.
|
||||||
|
expect(body).toContain('tail=${TERMINAL_TAIL_SIZE}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,7 +108,9 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
|
|||||||
|
|
||||||
it('is wired into _maybeRefetchFullHistory BEFORE the destructive reset', () => {
|
it('is wired into _maybeRefetchFullHistory BEFORE the destructive reset', () => {
|
||||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
const start = source.indexOf('async _maybeRefetchFullHistory()');
|
// Anchor on the open paren, not the full empty signature: the method takes
|
||||||
|
// options since #258 ({ force }) and this guard is about ORDER, not arity.
|
||||||
|
const start = source.indexOf('async _maybeRefetchFullHistory(');
|
||||||
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
|
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
|
||||||
const reset = source.indexOf('this._resetTerminalForReplay()', start);
|
const reset = source.indexOf('this._resetTerminalForReplay()', start);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user