Compare commits

..
Author SHA1 Message Date
Codeman maintainer df1a493b8c perf(terminal): tab switch to a pane with no scrollback loads only its screen
After a session's first load per page, a tab switch fetched a 1 MiB tail of
the server's byte stream. For a fullscreen claude pane (alternate screen,
tmux `history_size` 0) that tail is old repaints of one frame, and an idle
tab parsed it twice (the cached copy, then the fresh one).

Every terminal response now records `paneHistoryLines` per session, and
`selectSession` takes `full=1` whenever the last report was 0. For such a
pane that capture is the visible frame, a few KB, which is also what a page
load already shows. A response without the field forgets the entry, so
unknown never counts as empty; a pane that starts keeping history goes back
to the tail on the next switch. Shells and the refresh path are unchanged.

Measured with real prod payloads through the real client in chromium:
median switch 1.26-1.66 s -> 0.10-0.19 s, parse ~1.1-1.5 s -> 2-3 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:25:00 +02:00
Codeman maintainer e2453fe97b fix(terminal): partial-history notice only when there is history to load
The "Showing the most recent 1.0 MB of this session. 4.8 MB more may still
be retained." bar covered the top of the terminal on nearly every tab switch,
and for fullscreen claude panes it could never be acted on: `truncated`
measures the server's byte stream, while Load full history asks tmux, which
holds no scrollback for a pane that lives in the alternate screen.

- The capture reads `#{history_size}` in the cursor query it already makes
  and the terminal route returns it as `paneHistoryLines`.
- A reported 0 hides the notice; a positive count replaces the byte gap in
  the message.
- The notice is lazy: it appears once a wheel/touch gesture reaches the top
  of the browser buffer (after the pull that gesture starts), leaves when
  the user scrolls back to live output or switches tabs, and its dismissal
  sticks per session until reload.
- formatHistoryBytes no longer prints "1024 KB" just under 1 MiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:56:37 +02:00
Codeman maintainer 89177651a6 perf(sessions): closing a session no longer waits on the server
Closing a tab took ~0.55-0.7s on an idle machine, more with child
processes or recently active subagents. Most of it was waiting:

- The web UI kept the tab until DELETE returned, then removed it on the
  100ms tab-render debounce, which every session update restarts.
  closeSession() is now optimistic: the tab, tile and split go and the
  next session is selected before the request is sent, rendered at
  once. A refused delete (checked with a GET, since a delete can land
  and lose its reply) puts the row back at its old index with the
  error toast. This also fixes a latent bug: _apiDelete never throws,
  so an HTTP error used to report "Session closed" while the session
  kept running. SSE upserts skip ids that are being closed.
- The kill path slept fixed intervals (100ms PTY grace, 200ms for the
  pane's children, 100ms for the process group) and verified in 100ms
  steps. waitForProcessesExit() (utils/process-exit-wait.ts) keeps
  every deadline but returns once the processes are gone, counting a
  zombie as exited. Signal decisions keep kill(pid, 0).
- tmux kill-session and the pane-pid lookup ran via execSync, freezing
  the server for ~70ms per close. Now async.
- killSubagentsForSession() ran a full `pgrep -f claude` scan per
  active/idle subagent (~85ms each with ~100 matching processes). It
  now scans once for all of them.

Measured on an isolated instance: click to tab gone 540-690ms -> 58-95ms;
DELETE of a claude session ~450ms -> ~200-260ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:20:46 +02:00
Codeman maintainer 9644892a5a docs(readme): annotated tab-states and codeman-skill GIFs
Tab Alerts now shows a 17.5 s seamless loop of real sessions: a working
tab (spinning green ring), a red tab blocked on a real AskUserQuestion,
and a yellow tab whose turn is done, with a magnified tab strip and one
callout per state. It replaces the 2026-08-15 glow strip.

The agent skill section gets a time-lapse of a real run: one plain
English request, the codeman skill spawning three Claude Code workers as
new tabs, and the lineage lines drawing in, with numbered callouts that
appear as each step happens.

Both GIFs are 1920x1080 and link to 4800x2700 annotated stills.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:52:57 +02:00
Codeman maintainer ed6f5f6856 docs(readme): hero CRT tile grid now shows the live header strip
The README hero (both languages) is the same six-tile CRT loop, now with
the header's live stats strip: WS, CPU, memory and the Claude 5H/7D plan
usage chip, captured from a live Codeman with real numbers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:15:15 +02:00
Codeman maintainer 586aafa8de docs: refresh the annotated dashboard tour for the 1.40 layout
The README and wiki tour image still showed the 1.7.0 UI. The new one is
a live capture of 1.40.0 (compact header pills with the plan-usage chip
beside them, File Viewer and Tiles buttons, tab logos, Run CC) with the
same three callouts: session tabs, live plan usage, one-click Run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:58:15 +02:00
Devvyn b3d3c647cf feat(notifications): configurable toast and browser-notification display time (#564)
Squash-merged so the toast-history half, dropped during review, stays out of master's history.
2026-10-10 02:52:38 +02:00
Codeman maintainer 1b3f40bba7 docs(readme): drop the star call-to-action from the header
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:45:36 +02:00
Codeman maintainer fc7ffe1ad8 docs(readme): lead with the CRT tile grid animation
The hero GIF is now six live agents (DeepSeek Harness, Claude Code, Pi,
Codex, OpenCode, a shell) powering on and off in the tile grid, captured
frame-stepped at 60fps from a real instance. Replaces the July subagent
demo in both READMEs; the old GIF file stays in docs/images.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:43:14 +02:00
Codeman maintainer b59145effd feat(templates): new cases' CLAUDE.md asks for absolute file paths and points at the codeman skill
Codeman turns absolute paths in the terminal into links that open the file
viewer, but agents usually report created files as relative paths, which
cannot be clicked. The generated CLAUDE.md now asks for the full absolute
path of every created file in the final reply, and says why relative, ~/
and markdown-link forms do not work.

It also tells the agent about the codeman skill (start, prompt, wait on and
clean up worker sessions) when the skill is available, and how the user can
install it when it is not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 23:28:55 +02:00
47 changed files with 1561 additions and 102 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
Switching to a fullscreen Claude tab is about ten times faster. Claude keeps that conversation itself and tmux holds no scrollback for it, so instead of downloading and replaying a 1 MB tail of old screen redraws on every switch, the browser loads just the current screen (a few KB), the same as a page load already did. Tabs whose pane does keep scrollback are unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
+1 -1
View File
@@ -248,7 +248,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**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 `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [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 `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [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 its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
+7 -7
View File
@@ -19,16 +19,12 @@
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
@@ -54,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
<p align="center">
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
</p>
---
@@ -426,7 +422,7 @@ The title is templated into the served HTML on first byte, so it's correct from
### Tab Alerts
<p align="center">
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="Tab states, annotated: a working tab with a spinning green ring, a red tab blocked on the agent's question shown below it, and a yellow tab whose turn is done, both alert tabs breathing" width="900"></a>
</p>
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
@@ -739,6 +735,10 @@ For AI agents and automation that control Codeman without a browser: an agent th
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
<p align="center">
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
</p>
#### Step 1: install it
| How | Command | Scope |
+1 -5
View File
@@ -24,11 +24,7 @@
</p>
<p align="center">
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
</p>
<p align="center">
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
</p>
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
+1
View File
@@ -45,6 +45,7 @@ export const BROWSER_TEST_GLOBS = [
'test/spreadsheet-preview.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
'test/fullscreen-tab-switch-capture.browser.test.ts',
];
/**
+7 -2
View File
@@ -457,8 +457,13 @@ client that opens many concurrent waits against one session will still hit the c
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
is the number of scrollback rows tmux holds above the visible frame, which is the
most a `full=1` request can add. `truncated` describes the byte stream instead: for a
pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a
`tail` cut dropped are earlier repaints that no request returns. The capture runs
synchronous tmux calls on the server; the `Server-Timing` header reports `capture`,
`prepare` and `total`.
| Query | Meaning |
|---|---|
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+5 -1
View File
@@ -146,7 +146,11 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
### Notifications
Master toggle, browser notifications, push subscription, audio alerts, the idle
Master toggle, browser notifications, push subscription, audio alerts, how long a
corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
how long a desktop notification stays up before Codeman closes it (**Browser
notification display time**, default 8; both per device, and your OS may close a
desktop notification sooner), the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
+7 -2
View File
@@ -6,7 +6,7 @@ Most of Codeman's UI is **opt-in**. A stock install shows a deliberately small h
feature you read about here may simply not be on screen yet. Where that is the case, this
page says so and names the setting.
![Codeman dashboard](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-tour-20260724.png)
![Codeman dashboard](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-tour-20261010.png)
## Layout
@@ -210,9 +210,14 @@ TUIs render correctly.
Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
A fullscreen Claude tab keeps its history inside Claude, so switching back to it
reloads only the current screen, which keeps tab switches fast.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. Automatic output
history; press **Load full history** to pull the rest explicitly. That notice only
appears once you scroll to the top, leaves when you scroll back down, and stays away
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
Claude) never show it, since there is nothing more to load. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
+9
View File
@@ -204,6 +204,15 @@ export interface PaneCaptureOptions {
* rendering it needs the real height to know the frame fits.
*/
capturedGeometry?: { cols: number; rows: number };
/**
* Filled in by the implementation with the number of rows the pane holds in
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
* same query as the geometry. 0 means a full-history capture can return
* nothing beyond the visible frame: a pane in the alternate screen (a
* fullscreen CLI that keeps its transcript itself) never accumulates any.
* Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
}
/**
+9 -3
View File
@@ -106,6 +106,7 @@ import {
getClaudeBinaryPath,
spawnPtyWithHelperRepair,
resolveLocalShell,
waitForProcessesExit,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
@@ -179,7 +180,10 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
/** Graceful shutdown delay when stopping session (100ms) */
/**
* Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
* session. A deadline, not a sleep: stop() moves on as soon as it has exited.
*/
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
@@ -4801,8 +4805,10 @@ export class Session extends EventEmitter {
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
}
// Give it a moment to terminate gracefully
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
// Give it a moment to terminate gracefully. For a tmux-backed session this
// is the attach client, gone within a few ms of SIGTERM, and this used to be
// a fixed 100ms sleep on every close.
if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
// Force kill with SIGKILL if still alive
try {
+28 -6
View File
@@ -761,13 +761,35 @@ export class SubagentWatcher extends EventEmitter {
* by workingDir alone would kill subagents belonging to OTHER sessions.
*/
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
const subagents = this.getSubagentsForSession(workingDir);
for (const agent of subagents) {
if (agent.status === 'active' || agent.status === 'idle') {
// Only kill subagents belonging to this specific session
if (sessionId && agent.sessionId !== sessionId) continue;
await this.killSubagent(agent.agentId);
const targets = this.getSubagentsForSession(workingDir).filter(
// Only kill subagents belonging to this specific session
(agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
);
if (targets.length === 0) return;
// ONE process scan for the lot. This used to call killSubagent() per agent, and
// each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
// box with ~100 matching processes), so closing a session right after a workflow
// paid that once per recently active subagent. The match rules are
// findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
const pidMap = await this.getClaudePids();
const signalled = new Set<number>();
for (const agent of targets) {
// The liveness checker may have completed it while the scan ran.
if (agent.status !== 'active' && agent.status !== 'idle') continue;
for (const [pid, procInfo] of pidMap) {
if (signalled.has(pid)) continue;
if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
signalled.add(pid);
try {
process.kill(pid, 'SIGTERM');
} catch {
// Process may have already exited
}
break; // one process per agent, as killSubagent() does
}
}
this.markSubagentAsCompleted(agent);
}
}
+2
View File
@@ -56,3 +56,5 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
- The session persists across disconnects — your work is safe.
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
- After creating a file, write out its full absolute path in your final reply, e.g. `/home/me/project/docs/report.md`. Codeman makes absolute paths in the terminal clickable and opens them in its file viewer; a relative path (`docs/report.md`), a `~/` path or a markdown link (`[report](...)`) cannot be clicked.
- If the `codeman` skill is available, use it to start other Codeman sessions as workers, send them prompts, wait for them to finish, read their output and clean them up. When asked to parallelize work and the skill is missing, tell the user they can install it with `codeman skill install`.
+68 -27
View File
@@ -94,7 +94,13 @@ import {
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
resolveLocalShell,
loginShellArgs,
waitForProcessesExit,
} from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -144,12 +150,18 @@ const TMUX_CREATION_WAIT_MS = 100;
const GET_PID_MAX_RETRIES = 5;
const GET_PID_RETRY_MS = 200;
/** Delay after tmux kill command (200ms) */
/**
* How long killSession gives a pane's children to exit on SIGTERM before it
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
*/
const TMUX_KILL_WAIT_MS = 200;
/** Delay for graceful shutdown (100ms) */
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** How long killSession waits for every process it signalled to be gone before it warns. */
const KILL_VERIFY_TIMEOUT_MS = 2000;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
@@ -688,6 +700,12 @@ interface PaneCursorGeometry {
rows: number;
cursorX: number;
cursorY: number;
/**
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
* full-history capture can add. Absent when the query did not return it.
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines?: number;
}
/**
@@ -704,7 +722,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -717,7 +735,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
) {
return null;
}
return { cols, rows, cursorX, cursorY };
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
return geometry;
}
/** SGR attributes, which is all `capture-pane -e` emits. */
@@ -2350,6 +2370,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* {@link getPanePid} without blocking the event loop, for the kill path: a close
* must not stall every other session's I/O while tmux answers.
*/
private async getPanePidAsync(muxName: string): Promise<number | null> {
if (IS_TEST_MODE) return 99999;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
return null;
}
try {
const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
const pid = parseInt(stdout.trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
/**
* Check if a tmux session exists.
*/
@@ -2604,7 +2646,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
}
// Check if a process is still alive
// Check if a process is still alive. Signal decisions only: an unreaped zombie
// counts here, so its process group still gets the SIGKILL below. The WAITS use
// waitForProcessesExit(), which counts a zombie as exited.
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -2614,20 +2658,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Verify all PIDs are dead, with retry
// Verify all PIDs are dead, returning as soon as they are
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
const startTime = Date.now();
const checkInterval = 100;
while (Date.now() - startTime < maxWaitMs) {
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
if (aliveCount === 0) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, checkInterval));
}
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
if (stillAlive.length > 0) {
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
}
@@ -2685,7 +2718,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (isValidMuxName(session.muxName)) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2702,7 +2735,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Get current PID (may have changed)
const currentPid = this.getPanePid(session.muxName) || session.pid;
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
@@ -2724,7 +2757,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
// Most children are gone within a few ms; the re-scan below still runs, to
// catch anything spawned since the first one.
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
childPids = await this.getChildPidsFresh(currentPid);
for (const childPid of childPids) {
@@ -2742,7 +2777,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (this.isProcessAlive(currentPid)) {
try {
process.kill(-currentPid, 'SIGTERM');
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
if (this.isProcessAlive(currentPid)) {
process.kill(-currentPid, 'SIGKILL');
}
@@ -2751,10 +2786,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
// Async: tmux takes tens of ms to tear a session down, and execSync held the
// whole server for that long on every close.
if (isValidMuxName(session.muxName)) {
try {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2798,7 +2835,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Verify all processes are dead
const allDead = await this.verifyProcessesDead(allPids, 2000);
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
if (!allDead) {
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
}
@@ -3861,7 +3898,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3873,6 +3910,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
// Same query, no extra tmux call: how much scrollback a full-history pull
// could return. A pane in the alternate screen (fullscreen claude) holds
// none, and the partial-history notice must not promise it.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+1
View File
@@ -12,6 +12,7 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
+76
View File
@@ -0,0 +1,76 @@
/**
* @fileoverview Wait for processes to exit, and return as soon as they have.
*
* The session kill path used to sleep a FIXED interval after each signal (100 ms
* for the PTY client, 200 ms for the pane's children, 100 ms for the process
* group) and then verified in 100 ms steps. A process that was gone after 3 ms
* still cost the whole interval, so closing a tab spent most of its ~0.5 s in
* timers. This keeps every deadline the kill path had; it only stops waiting
* once there is nothing left to wait for.
*
* A zombie counts as exited. It holds nothing but its pid until the parent reaps
* it, and on the kill path that parent is the tmux server or the service
* manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
* zombie from a running process, so on Linux the state letter in
* `/proc/<pid>/stat` decides; without procfs `kill(pid, 0)` is the answer.
*
* @module utils/process-exit-wait
*/
import { readFileSync } from 'node:fs';
/** Poll step while waiting: an exit is noticed within about one frame. */
export const PROCESS_EXIT_POLL_MS = 10;
/**
* True while `pid` names a process that has not exited. A pid we may not signal
* reads as not running, which is what the kill path's own check always did:
* there is nothing it could do about such a process anyway.
*/
export function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
} catch {
return false;
}
if (process.platform !== 'linux') return true;
let stat: string;
try {
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
} catch (err) {
// Gone between the two reads. Any other failure: trust kill(pid, 0).
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
}
// "<pid> (<comm>) <state> …": comm may hold spaces and parentheses itself,
// so the state is the field after the LAST ')'.
const close = stat.lastIndexOf(')');
const state = close === -1 ? '' : stat.charAt(close + 2);
return state !== 'Z' && state !== 'X';
}
export interface WaitForExitOptions {
/** Give up after this long; the survivors are returned, never thrown. */
timeoutMs: number;
/** Poll step, {@link PROCESS_EXIT_POLL_MS} by default. */
pollMs?: number;
/** Liveness probe, {@link isProcessRunning} by default (injectable for tests). */
isRunning?: (pid: number) => boolean;
}
/**
* Resolve once every pid in `pids` has exited, or at the deadline with the ones
* that have not. Never rejects.
*/
export async function waitForProcessesExit(pids: readonly number[], options: WaitForExitOptions): Promise<number[]> {
const isRunning = options.isRunning ?? isProcessRunning;
const pollMs = Math.max(1, options.pollMs ?? PROCESS_EXIT_POLL_MS);
const deadline = Date.now() + options.timeoutMs;
let running = pids.filter((pid) => isRunning(pid));
while (running.length > 0) {
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
running = running.filter((pid) => isRunning(pid));
}
return running;
}
+154 -24
View File
@@ -767,6 +767,12 @@ class CodemanApp {
// their scrollback can be very large; full history stays available on demand.
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
this._fullHistoryLoaded = new Set();
// Rows of scrollback tmux reported above the visible frame on this session's
// last capture (`paneHistoryLines`). 0 marks a pane with no history to load
// (a fullscreen CLI in the alternate screen), which a tab switch serves from
// the small `full=1` capture instead of the 1 MiB byte tail: see
// _notePaneHistory and selectSession.
this._paneHistoryLines = new Map(); // Map<sessionId, number>
// Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false;
@@ -2286,6 +2292,8 @@ class CodemanApp {
}
_onSessionCreated(data) {
// A session this tab is closing stays closed until the server answers.
if (this._closingSessions?.has(data.id)) return;
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
@@ -2310,6 +2318,9 @@ class CodemanApp {
_onSessionUpdated(data) {
const session = data.session || data;
// A session this tab is closing stays closed until the server answers
// (closeSession() puts it back if the delete is refused).
if (this._closingSessions?.has(session.id)) return;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
this.sessions.set(session.id, session);
@@ -3188,6 +3199,7 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, 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.
@@ -5954,9 +5966,19 @@ class CodemanApp {
// Session Tabs
// ═══════════════════════════════════════════════════════════════
renderSessionTabs() {
renderSessionTabs({ immediate = false } = {}) {
// Don't re-render while user is typing in the inline rename input
if (this._inlineRenameActive) return;
if (immediate) {
// For a change the user just made and is watching for (closing a tab). The
// debounce restarts on every session update, so with busy sessions around a
// debounced pass can lag well past its 100ms. A pass still pending would only
// repeat this one, so it is dropped.
clearTimeout(this._debounceTimers.sessionTabs);
this._debounceTimers.sessionTabs = null;
this._renderSessionTabsImmediate();
return;
}
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -8537,6 +8559,7 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -8674,6 +8697,9 @@ class CodemanApp {
// 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,
// tmux scrollback above the frame (null = not reported). 0 is a pane with
// nothing a pull could add, which hides the notice outright.
paneHistoryLines: payload.paneHistoryLines ?? null,
});
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
@@ -8681,9 +8707,45 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
this._paneHistoryLines?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* Remember how much scrollback tmux holds for a session's pane, from any
* terminal response. Called whether or not the response was written, since
* it describes the PANE, not the payload. A response without the field (a
* byte-history fallback, an older server) forgets it, so the next tab switch
* goes back to the bounded tail: unknown never counts as empty.
*/
_notePaneHistory(sessionId, payload) {
if (!sessionId) return;
const lines = payload?.paneHistoryLines;
if (typeof lines === 'number' && Number.isFinite(lines) && lines >= 0) {
(this._paneHistoryLines ||= new Map()).set(sessionId, lines);
} else {
this._paneHistoryLines?.delete(sessionId);
}
}
/**
* Bring the partial-history notice up for `sessionId`, or retire it (null).
*
* The notice is LAZY: a tail replay is truncated on nearly every tab switch,
* and a bar over the top rows on every switch described history the user had
* not reached for. It now waits for the scroll gesture that reaches the top of
* the browser's buffer (the moment the missing part matters, and the same
* gesture that already re-pulls history), and goes away again once the user
* scrolls back down to live output. A tab switch retires it (selectSession).
*/
_setHistoryNoticeRevealed(sessionId) {
const next = sessionId || null;
if ((this._historyNoticeRevealedFor ?? null) === next) return;
this._historyNoticeRevealedFor = next;
this._renderHistoryTruncationBanner();
}
/**
* Paint the partial-history banner for the active session.
*
@@ -8693,13 +8755,22 @@ class CodemanApp {
* - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling
*
* Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled
* to the top of this tab's buffer) and never again for a session whose notice
* the user dismissed on this page.
*/
_renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar');
if (!bar) return;
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
const sessionId = this.activeSessionId;
const state = sessionId ? this._historyTruncation?.get(sessionId) : null;
const notice = computeHistoryTruncationNotice(state || {});
if (!notice.visible) {
if (
!notice.visible ||
this._historyNoticeRevealedFor !== sessionId ||
this._historyNoticeDismissed?.has(sessionId)
) {
bar.hidden = true;
return;
}
@@ -8732,6 +8803,9 @@ class CodemanApp {
dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×';
dismiss.onclick = () => {
// Sticky for this session until the page reloads: a dismissed notice used
// to come straight back on the next tab switch.
(this._historyNoticeDismissed ||= new Set()).add(sessionId);
bar.hidden = true;
};
bar.appendChild(dismiss);
@@ -8845,7 +8919,9 @@ class CodemanApp {
this._activateFileBrowserSession?.(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).
// session's notice stays on screen until then (#258). The switch lands at
// live output, so the notice waits for a scroll to the top again.
this._historyNoticeRevealedFor = null;
this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming
@@ -9116,7 +9192,23 @@ class CodemanApp {
// automatically replaying all of them makes tab selection scale with the
// entire session. Load its bounded 1MB tail first; the existing truncation
// banner action fetches ?full=1 when the user explicitly asks for it.
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
//
// A TUI pane whose last capture reported NO tmux scrollback (fullscreen
// claude: it lives in the alternate screen and keeps its transcript itself)
// takes `full=1` on every switch, not only the first. For such a pane that
// capture IS the visible frame, a few KB, while the tail is 1 MiB of the
// byte stream's old repaints. Measured on 11 live fullscreen claude panes:
// the tail took 250-1070 ms on the server and 70-510 ms to parse, and
// rendered one frame repeated (450 rows, 44 distinct); `full=1` took
// 150-330 ms and returned 0.9-5.6 KB. It also matches what a page load
// already shows, and an empty local buffer is what lets
// _maybePageCliTranscript send a wheel to the CLI's own transcript. The
// response re-reports the count, so a pane that starts keeping history
// (claude switched to its inline view) goes back to the tail on the next
// switch.
const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;
const useFullHistory =
session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
@@ -9146,6 +9238,7 @@ class CodemanApp {
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
@@ -9639,27 +9732,32 @@ class CodemanApp {
}
async closeSession(sessionId, killMux = true) {
// ⚠️ Captured BEFORE the await, and the delete is announced to
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
// broadcast for THIS delete routinely lands while the request is still in
// flight, and that handler nulls activeSessionId and shows the welcome
// screen. Re-reading the field after the await therefore made the fallback
// below a coin flip: closing the tab you were on either moved you to the
// next session or dumped you on the home screen, depending on which path
// won the race (both outcomes measured on one build, 2026-08-17).
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
// delete puts the row back below.
//
// ⚠️ Everything is read BEFORE the first await, and the delete is announced
// to _onSessionDeleted through _closingSessions: the `session_deleted` SSE
// broadcast for THIS delete arrives while the request is still in flight, and
// that handler must leave the follow-up selection to this method (both
// outcomes of that race were measured on one build, 2026-08-17).
const session = this.sessions.get(sessionId);
const orderIndex = this.sessionOrder.indexOf(sessionId);
const wasActive = this.activeSessionId === sessionId;
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
// sessionOrder entry (often not tiled, which would collapse the grid).
// Captured here for the same reason as wasActive: the SSE delete can remove
// the tile while the request is still in flight.
const grid = this._tileGrid;
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
this._closingSessions.add(sessionId);
let res = null;
try {
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
// The last tile leaving closes the grid (no reselect): the pick below runs.
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
// The same teardown the SSE event runs (split pane, tile, detached window,
// WebSocket, per-session state), only now instead of when the server is
// done. It is idempotent, so the real event finds nothing left to do.
this._onSessionDeleted({ id: sessionId });
if (wasActive && grid?.open) {
// `auto`: the app chose this tile because the previous one went away.
@@ -9684,18 +9782,50 @@ class CodemanApp {
}
}
this.renderSessionTabs();
this.renderSessionTabs({ immediate: true });
res = await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
} catch (err) {
// `res` stays null: handled below like a delete that got no answer.
console.warn('[closeSession] close failed:', err);
} finally {
this._closingSessions.delete(sessionId);
}
// 404: already gone (closed from another tab or device), which is what was asked.
let gone = !!res && (res.ok || res.status === 404);
if (!gone) {
// Refused, or no answer. Ask rather than guess: a delete can land on the
// server and still lose its reply, and its session_deleted event has then
// already been spent while the request was in flight.
const check = await this._api(`/api/sessions/${sessionId}`);
gone = check?.status === 404;
}
if (gone) {
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
// the server, which still had the session then.
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
} catch (err) {
this.showToast('Failed to close session', 'error');
} finally {
this._closingSessions.delete(sessionId);
return;
}
// Still on the server (or the server is unreachable, in which case the resync
// on reconnect has the last word): its row comes back where it was.
if (session && !this.sessions.has(sessionId)) {
this.sessions.set(sessionId, session);
if (!this.sessionOrder.includes(sessionId)) {
const at = orderIndex === -1 ? this.sessionOrder.length : Math.min(orderIndex, this.sessionOrder.length);
this.sessionOrder.splice(at, 0, sessionId);
this.saveSessionOrder();
}
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
}
// Request confirmation before closing a session
+30 -3
View File
@@ -110,6 +110,9 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
@@ -1741,7 +1744,10 @@ function escapeHtml(text) {
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`;
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
// just under 1 MiB and used to print as "1024 KB".
const kb = Math.round(n / 1024);
if (kb < 1024) return `${kb} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -1755,12 +1761,27 @@ function formatHistoryBytes(bytes) {
* - atCeiling: the FULL capture itself hit the byte ceiling
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
* and the button only ever ended in the downgrade refusal. Nothing to offer,
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* paneHistoryLines?: number|null}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
const historyLines =
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
? Math.max(0, state.paneHistoryLines)
: null;
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
const retained = Math.max(0, state.retainedBytes || 0);
const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1783,9 +1804,15 @@ function computeHistoryTruncationNotice(state = {}) {
canLoadMore: false,
};
}
// Name what a pull can actually return when the server said: the byte gap
// counts repaints and redraw bloat, and overstates it many times over.
const more =
historyLines !== null
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
: `${formatHistoryBytes(dropped)} more may still be retained.`;
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
message: `Showing the most recent ${shown} of this session. ${more}`,
canLoadMore: true,
};
}
+20
View File
@@ -2723,6 +2723,26 @@
<span class="set-unit">min</span>
</div>
</div>
<div class="set-row" data-search="toast display time popup dismiss seconds notification">
<div class="set-row-text">
<span class="set-row-label">Toast display time</span>
<span class="set-row-desc">How long the corner pop-ups stay on screen.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsNotifToastSecs" class="set-num" value="3" min="1" max="300">
<span class="set-unit">sec</span>
</div>
</div>
<div class="set-row" data-search="browser notification auto close dismiss seconds">
<div class="set-row-text">
<span class="set-row-label">Browser notification display time</span>
<span class="set-row-desc">How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsNotifBrowserSecs" class="set-num" value="8" min="1" max="300">
<span class="set-unit">sec</span>
</div>
</div>
</div>
</div>
+20 -3
View File
@@ -4,7 +4,7 @@
* The NotificationManager class implements five notification layers:
* 1. In-app notification drawer (slide-out panel with grouped notifications)
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
* 3. Browser Notification API (desktop push with auto-close after 8s)
* 3. Browser Notification API (desktop push; auto-closes after 8s by default, configurable per device in Settings → Notifications)
* 4. Web Push via service worker (OS-level notifications when tab is closed)
* 5. Audio alerts (Web Audio API beep, user-opt-in)
*
@@ -93,6 +93,10 @@ class NotificationManager {
browserNotifications: !isMobile,
audioAlerts: false,
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
// How long a corner toast stays on screen, and how long a browser notification
// stays up before Codeman closes it (ms; per-device like the rest of these)
toastDurationMs: DEFAULT_TOAST_DURATION_MS,
browserAutoCloseMs: AUTO_CLOSE_NOTIFICATION_MS,
// Legacy urgency muting (keep for backwards compat)
muteCritical: false,
muteWarning: false,
@@ -167,11 +171,24 @@ class NotificationManager {
return {
...defaults,
...prefs,
toastDurationMs: this.clampDuration(prefs.toastDurationMs, defaults.toastDurationMs),
browserAutoCloseMs: this.clampDuration(prefs.browserAutoCloseMs, defaults.browserAutoCloseMs),
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
_version: 5,
};
}
/** A display time in ms kept within [1s, 5min]; anything unusable falls back to the default. */
clampDuration(value, fallback) {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(MAX_NOTIFICATION_DURATION_MS, Math.max(MIN_NOTIFICATION_DURATION_MS, Math.round(value)));
}
/** Display time for corner toasts that do not set their own `duration`. */
getToastDurationMs() {
return this.clampDuration(this.preferences?.toastDurationMs, DEFAULT_TOAST_DURATION_MS);
}
loadPreferences() {
try {
const storageKey = this.getStorageKey();
@@ -403,7 +420,7 @@ class NotificationManager {
};
// Auto-close
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS));
}
async requestPermission() {
@@ -466,7 +483,7 @@ class NotificationManager {
notif.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
}
}
// Switch to session if available
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
+3 -2
View File
@@ -5862,7 +5862,8 @@ Object.assign(CodemanApp.prototype, {
},
/**
* `duration` defaults to 3000ms for every toast type. A message worth
* `duration` defaults to the "Toast display time" preference (3000ms unless changed in
* Settings → Notifications) for every toast type. A message worth
* reading rather than glancing at (e.g. "Session started on the native
* backend — could not apply the custom endpoint: <the actual reason>")
* passes an explicit `opts.duration: 0` at its own call site instead of
@@ -5874,7 +5875,7 @@ Object.assign(CodemanApp.prototype, {
* regardless of duration.
*/
showToast(message, type = 'info', opts = {}) {
const { duration = 3000, action } = opts;
const { duration = this.notificationManager?.getToastDurationMs?.() ?? 3000, action } = opts;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
+9 -1
View File
@@ -563,6 +563,12 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
document.getElementById('appSettingsNotifToastSecs').value = Math.round(
(this.notificationManager?.getToastDurationMs?.() ?? DEFAULT_TOAST_DURATION_MS) / 1000
);
document.getElementById('appSettingsNotifBrowserSecs').value = Math.round(
(notifPrefs.browserAutoCloseMs ?? AUTO_CLOSE_NOTIFICATION_MS) / 1000
);
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
@@ -2667,6 +2673,8 @@ Object.assign(CodemanApp.prototype, {
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
toastDurationMs: (parseInt(document.getElementById('appSettingsNotifToastSecs').value) || 3) * 1000,
browserAutoCloseMs: (parseInt(document.getElementById('appSettingsNotifBrowserSecs').value) || 8) * 1000,
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
@@ -2736,7 +2744,7 @@ Object.assign(CodemanApp.prototype, {
_version: 5,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
this.notificationManager.preferences = this.notificationManager.normalizePreferences(notifPrefsToSave);
this.notificationManager.savePreferences();
}
+16 -2
View File
@@ -3627,10 +3627,24 @@ Object.assign(CodemanApp.prototype, {
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
* the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason.
*
* It is also what brings up the partial-history notice, and what retires it
* once a downward scroll is back at live output (_setHistoryNoticeRevealed).
* The reveal waits for the pull this gesture started, so the notice describes
* what the pull left rather than flashing the state it is about to replace.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines >= 0) return;
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
if (lines > 0) {
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
return;
}
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
const sessionId = this.activeSessionId;
Promise.resolve(this._maybeRefetchFullHistory?.())
.catch(() => {})
.then(() => {
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
});
},
/**
+6
View File
@@ -3225,6 +3225,12 @@ export function registerSessionRoutes(
// damage that does not exist.
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
// Rows tmux holds above the visible frame, which is the most a `full=1`
// pull can add. `truncated` measures the BYTE stream, and for a pane that
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
// a tail cut drops are old repaints that no request can bring back, so a
// client must not offer to load them. Absent when the pane was not read.
paneHistoryLines: hasLiveMuxBuffer ? captureOpts.capturedHistoryLines : undefined,
};
});
+2
View File
@@ -1517,6 +1517,8 @@ export const SettingsUpdateSchema = z
browserNotifications: z.boolean().optional(),
audioAlerts: z.boolean().optional(),
stuckThresholdMs: z.number().optional(),
toastDurationMs: z.number().optional(),
browserAutoCloseMs: z.number().optional(),
muteCritical: z.boolean().optional(),
muteWarning: z.boolean().optional(),
muteInfo: z.boolean().optional(),
@@ -0,0 +1,182 @@
/**
* @fileoverview A tab switch to a pane with no tmux scrollback takes the small capture.
*
* After the first select of a page, a tab switch to a non-shell session used to
* fetch a 1 MiB tail of the server's byte stream. For a fullscreen claude pane
* (alternate screen, `#{history_size}` 0) that tail is old repaints of one frame:
* measured on live panes it cost 250-1070 ms on the server and 70-510 ms to
* parse, and an idle tab parsed it twice (the cached copy, then the fresh one,
* which never matched). The `full=1` capture of such a pane is the visible
* frame, a few KB, so `selectSession` now takes it whenever the session's last
* capture reported `paneHistoryLines: 0`, and goes back to the tail as soon as a
* capture reports scrollback again.
*
* Drives the real client in chromium against a testMode server, with the
* terminal route stubbed (the real one needs live tmux to report a hollow pane).
*
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*
* Run: npm run test:browser -- test/fullscreen-tab-switch-capture.browser.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 60_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
/** What each session's pane reports, set by the test as the "pane" changes. */
type PaneHistory = Record<string, number | undefined>;
/**
* Serve every terminal fetch from a stub and log which shape was asked for.
* `source` follows the real route: `full=1` answers `mux-full-history`, anything
* else `mux-visible`. No capture geometry, so the geometry retry stays out of it.
*/
async function stubTerminal(page: Page, history: PaneHistory, log: string[]) {
await page.route('**/api/sessions/*/terminal*', async (route) => {
const url = new URL(route.request().url());
const id = url.pathname.split('/')[3];
const full = url.searchParams.get('full') === '1';
log.push(`${id}:${full ? 'full' : 'tail'}`);
const frame = `frame of ${id}\r\n`;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: full ? frame : `${'old repaint\r\n'.repeat(50)}${frame}`,
status: 'idle',
fullSize: full ? frame.length : 5 * 1024 * 1024,
retainedBytes: full ? frame.length : 1024 * 1024,
truncated: !full,
truncationReason: full ? null : 'tail',
source: full ? 'mux-full-history' : 'mux-visible',
paneHistoryLines: history[id],
},
}),
});
});
}
async function openApp(page: Page): Promise<void> {
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
timeout: 30_000,
});
}
async function createSession(page: Page, name: string): Promise<string> {
return page.evaluate(async (n) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: n }),
});
const body = await res.json();
return body.data?.session?.id ?? body.data?.id ?? body.id;
}, name);
}
async function select(page: Page, sessionId: string): Promise<void> {
await page.evaluate(async (sid) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
}
async function deleteSession(page: Page, sessionId: string): Promise<void> {
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
}
describe('tab switch to a pane that keeps no scrollback', () => {
it('takes the small full capture on every switch, and the tail again once the pane keeps history', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
const page = await context.newPage();
await openApp(page);
const hollow = await createSession(page, 'fullscreen-claude');
const inline = await createSession(page, 'inline-claude');
const history: PaneHistory = { [hollow]: 0, [inline]: 40_000 };
const log: string[] = [];
await stubTerminal(page, history, log);
// First select of each on this page: the canonical full replay, as before.
await select(page, hollow);
await select(page, inline);
expect(log).toEqual([`${hollow}:full`, `${inline}:full`]);
// Switching back: the hollow pane takes the frame, the inline one the tail.
log.length = 0;
await select(page, hollow);
await select(page, inline);
await select(page, hollow);
expect(log).toEqual([`${hollow}:full`, `${inline}:tail`, `${hollow}:full`]);
// The pane starts keeping history (claude switched to its inline view). The
// switch that learns it is still a full capture, which is the canonical
// load anyway; the one after goes back to the bounded tail.
history[hollow] = 1200;
log.length = 0;
await select(page, inline);
await select(page, hollow);
await select(page, inline);
await select(page, hollow);
expect(log).toEqual([`${inline}:tail`, `${hollow}:full`, `${inline}:tail`, `${hollow}:tail`]);
// Unknown is never treated as empty: a response without the field (an
// older server, a byte-history fallback) keeps the tail.
history[hollow] = 0;
log.length = 0;
await select(page, inline);
await select(page, hollow); // learns 0 from this tail response
history[hollow] = undefined;
await select(page, inline);
await select(page, hollow); // last report was 0: frame, which reports nothing
await select(page, inline);
await select(page, hollow); // forgot it: tail
expect(log).toEqual([
`${inline}:tail`,
`${hollow}:tail`,
`${inline}:tail`,
`${hollow}:full`,
`${inline}:tail`,
`${hollow}:tail`,
]);
// What the hollow switch actually paints: the frame, without the repaints.
history[hollow] = 0;
await select(page, inline);
await select(page, hollow); // learns 0
await select(page, inline);
await select(page, hollow);
const screen = await page.evaluate(() => {
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
const lines: string[] = [];
for (let i = 0; i < t.buffer.active.length; i++) lines.push(t.buffer.active.getLine(i)?.translateToString(true));
return lines.join('\n');
});
expect(screen).toContain(`frame of ${hollow}`);
expect(screen).not.toContain('old repaint');
await deleteSession(page, hollow);
await deleteSession(page, inline);
await context.close();
}, 90_000);
});
+278
View File
@@ -0,0 +1,278 @@
/**
* @fileoverview The partial-history notice waits until the user reaches for history.
*
* Every tab switch after the first replays a 1 MiB TAIL of the session's byte
* stream, which is truncated for any session that has run for a while, so the
* notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still
* be retained.") covered the top rows on nearly every switch. Its × only lasted
* until the next switch. Now:
* - it appears only once a scroll gesture reaches the top of the browser's
* buffer, after the history pull that gesture starts has settled;
* - a scroll back down to live output retires it, and so does a tab switch;
* - a dismissal sticks for that session until the page reloads.
*
* Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook,
* constants.js notice decision) in a `vm` against a stub DOM, the same way
* shell-scroll-history-pull.test.ts does (no jsdom on this box).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const APP = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
interface FakeEl {
tagName: string;
hidden: boolean;
className: string;
type: string;
disabled: boolean;
children: FakeEl[];
attrs: Record<string, string>;
onclick: null | (() => void);
textContent: string;
appendChild(child: FakeEl): void;
setAttribute(name: string, value: string): void;
}
function fakeEl(tagName: string): FakeEl {
let text = '';
const el: FakeEl = {
tagName,
hidden: false,
className: '',
type: '',
disabled: false,
children: [],
attrs: {},
onclick: null,
get textContent() {
return text + el.children.map((c) => c.textContent).join('');
},
set textContent(value: string) {
text = value;
el.children = [];
},
appendChild(child) {
el.children.push(child);
},
setAttribute(name, value) {
el.attrs[name] = value;
},
};
return el;
}
/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */
function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function makeApp() {
const bar = fakeEl('div');
bar.hidden = true;
const document = {
getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null),
createElement: (tag: string) => fakeEl(tag),
};
const methods = [
'_setHistoryTruncation',
'_clearHistoryTruncation',
'_setHistoryNoticeRevealed',
'_renderHistoryTruncationBanner',
]
.map((m) => methodSource(APP, m))
.join(',\n');
const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } });
const appMethods = vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;({ ${methods} })`,
context,
{ filename: 'app-methods.js' }
) as Record<string, (...args: unknown[]) => unknown>;
const mixin = loadTerminalMixin();
const buffer = { viewportY: 500, baseY: 500 };
let resolvePull: (() => void) | null = null;
const app = {
activeSessionId: 's1' as string | null,
terminal: { buffer: { active: buffer } },
// Each gesture's pull is held open until the test settles it.
_maybeRefetchFullHistory: vi.fn(
() =>
new Promise<void>((res) => {
resolvePull = res;
})
),
...appMethods,
_maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll,
isTerminalAtBottom: mixin.isTerminalAtBottom,
} as Record<string, any>;
const settle = async () => {
resolvePull?.();
resolvePull = null;
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const scrollTo = async (viewportY: number) => {
const lines = viewportY - buffer.viewportY;
buffer.viewportY = viewportY;
app._maybeLoadMoreHistoryOnScroll(lines);
await settle();
};
return { app, bar, buffer, scrollTo, settle };
}
// What a tab switch's tail replay reports for a session with real scrollback.
const TAIL = {
truncated: true,
truncationReason: 'tail',
source: 'mux-visible',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
paneHistoryLines: 40000,
};
const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load');
const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss');
describe('partial-history notice: lazy reveal', () => {
it('stays hidden after a truncated tab-switch replay', () => {
const { app, bar } = makeApp();
app._setHistoryTruncation('s1', TAIL);
expect(bar.hidden).toBe(true);
});
it('appears once a scroll reaches the top, after the pull that gesture started', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1);
// Still pulling: no notice describing the state the pull is about to replace.
expect(bar.hidden).toBe(true);
await settle();
expect(bar.hidden).toBe(false);
expect(bar.textContent).toContain('40,000 lines of scrollback are retained.');
expect(loadButton(bar)?.textContent).toBe('Load full history');
});
it('shows what the pull left: nothing, when the pull brought everything back', async () => {
const { app, bar, buffer } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._maybeRefetchFullHistory.mockImplementation(async () => {
app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 });
});
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('does not appear on the way up, only at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(200);
expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled();
expect(bar.hidden).toBe(true);
});
it('goes away once the user scrolls back down to live output', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(false);
await scrollTo(300); // still reading history
expect(bar.hidden).toBe(false);
await scrollTo(500); // back at the bottom
expect(bar.hidden).toBe(true);
});
it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 });
await scrollTo(0);
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('is not revealed for a tab the user switched to while the pull ran', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._setHistoryTruncation('s2', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
app.activeSessionId = 's2';
await settle();
expect(app._historyNoticeRevealedFor ?? null).toBe(null);
expect(bar.hidden).toBe(true);
});
it('keeps a dismissal for that session, but only that session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
expect(bar.hidden).toBe(true);
// A new replay and another trip to the top do not bring it back.
await scrollTo(500);
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(true);
// Another session still gets its notice.
app.activeSessionId = 's2';
app._setHistoryTruncation('s2', TAIL);
await scrollTo(500);
await scrollTo(0);
expect(bar.hidden).toBe(false);
});
it('forgets the dismissal with the session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
app._clearHistoryTruncation('s1');
expect(app._historyNoticeDismissed.has('s1')).toBe(false);
});
});
describe('partial-history notice: a tab switch retires it (static guard)', () => {
it('selectSession clears the reveal before repainting the banner', () => {
const body = methodSource(APP, 'selectSession');
const reset = body.indexOf('this._historyNoticeRevealedFor = null;');
const render = body.indexOf('this._renderHistoryTruncationBanner();');
expect(reset).toBeGreaterThan(-1);
expect(render).toBeGreaterThan(reset);
});
});
+71 -1
View File
@@ -45,6 +45,11 @@ describe('formatHistoryBytes', () => {
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
});
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');
@@ -114,6 +119,65 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
});
});
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
const fullscreenTail = {
truncated: true,
reason: 'tail',
source: 'mux-visible',
fullSize: 6158853,
retainedBytes: 1048351,
};
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
// The dropped bytes were old repaints of one frame, and `full=1` returns
// only the visible frame for such a pane, so the button could only ever end
// in the downgrade refusal. That is the banner that showed on every switch.
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
});
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
false
);
expect(
computeHistoryTruncationNotice({
...fullscreenTail,
source: 'mux-full-history',
reason: 'capped',
paneHistoryLines: 0,
}).visible
).toBe(false);
});
it('names the scrollback lines a pull can load instead of the byte gap', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toBe(
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
);
expect(notice.message).not.toContain('4.9 MB');
});
it('uses the singular for one line', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
expect(notice.message).toContain('1 line of scrollback is retained.');
});
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
for (const paneHistoryLines of [undefined, null, NaN]) {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('more may still be retained');
}
});
});
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');
@@ -124,7 +188,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
// A shell never takes the full capture on a tab switch. A TUI takes it on its
// first select per page, and on every select while its pane keeps no tmux
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
expect(app).toContain(
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
);
expect(app).toContain('const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;');
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
// Every terminal capture now goes through _fetchTerminalCapture, which adds
+33
View File
@@ -23,6 +23,10 @@ type Manager = {
getStorageKey: () => string;
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
notify: (notification: Record<string, unknown>) => void;
getToastDurationMs: () => number;
unreadCount: number;
markAllRead: () => void;
clearAll: () => void;
};
const openWindows: JSDOM[] = [];
@@ -48,6 +52,10 @@ function loadManager(
STUCK_THRESHOLD_DEFAULT_MS: number;
GROUPING_TIMEOUT_MS: number;
NOTIFICATION_LIST_CAP: number;
AUTO_CLOSE_NOTIFICATION_MS: number;
DEFAULT_TOAST_DURATION_MS: number;
MIN_NOTIFICATION_DURATION_MS: number;
MAX_NOTIFICATION_DURATION_MS: number;
};
win.MobileDetection = {
getDeviceType: () => device.deviceType ?? 'desktop',
@@ -56,6 +64,10 @@ function loadManager(
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
win.GROUPING_TIMEOUT_MS = 5_000;
win.NOTIFICATION_LIST_CAP = 100;
win.AUTO_CLOSE_NOTIFICATION_MS = 8_000;
win.DEFAULT_TOAST_DURATION_MS = 3_000;
win.MIN_NOTIFICATION_DURATION_MS = 1_000;
win.MAX_NOTIFICATION_DURATION_MS = 300_000;
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
@@ -66,6 +78,7 @@ function loadManager(
}
win.eval(`
window.escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
${SOURCE}
window.__testNotificationManager = NotificationManager;
`);
@@ -148,3 +161,23 @@ describe('notification noise defaults', () => {
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
});
});
describe('notification display time', () => {
it('defaults to 3s toasts and 8s browser notifications', () => {
const { manager } = loadManager();
expect(manager.getToastDurationMs()).toBe(3000);
expect((manager.preferences as unknown as Record<string, number>).browserAutoCloseMs).toBe(8000);
});
it('honours a configured toast time and clamps unusable values', () => {
const { manager } = loadManager({ toastDurationMs: 15_000 });
expect(manager.getToastDurationMs()).toBe(15_000);
const clamp = (value: unknown) =>
(manager.normalizePreferences({ toastDurationMs: value }) as unknown as Record<string, number>).toastDurationMs;
expect(clamp(10)).toBe(1000);
expect(clamp(9_999_999)).toBe(300_000);
expect(clamp('soon')).toBe(3000);
expect(clamp(undefined)).toBe(3000);
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview What the client remembers about a pane's tmux scrollback.
*
* `selectSession` serves a tab switch from the small `full=1` capture when the
* session's last terminal response reported `paneHistoryLines: 0` (a fullscreen
* CLI in the alternate screen), instead of the 1 MiB byte tail. The memory that
* decides it must treat ONLY a reported 0 as empty: a missing field (an older
* server, a byte-history fallback) has to forget, so the tab switch goes back to
* the bounded tail rather than trusting a stale answer.
*
* Runs the real `_notePaneHistory` / `_clearHistoryTruncation` from app.js in a
* `vm`. The switch itself is driven end to end in
* fullscreen-tab-switch-capture.browser.test.ts (browser suite).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const APP = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
function makeApp() {
const methods = vm.runInContext(
`({ ${methodSource(APP, '_notePaneHistory')}, ${methodSource(APP, '_clearHistoryTruncation')} })`,
vm.createContext({})
) as Record<string, (...args: unknown[]) => void>;
return {
activeSessionId: null,
_paneHistoryLines: new Map<string, number>(),
_renderHistoryTruncationBanner() {},
...methods,
} as Record<string, any>;
}
describe('_notePaneHistory', () => {
it('records the count a capture reported, 0 included', () => {
const app = makeApp();
app._notePaneHistory('s1', { paneHistoryLines: 0 });
app._notePaneHistory('s2', { paneHistoryLines: 40000 });
expect(app._paneHistoryLines.get('s1')).toBe(0);
expect(app._paneHistoryLines.get('s2')).toBe(40000);
});
it('forgets on a response without a usable count, so unknown never reads as empty', () => {
const app = makeApp();
for (const payload of [{}, { paneHistoryLines: null }, { paneHistoryLines: NaN }, { paneHistoryLines: -1 }, null]) {
app._notePaneHistory('s1', { paneHistoryLines: 0 });
app._notePaneHistory('s1', payload);
expect(app._paneHistoryLines.has('s1')).toBe(false);
}
});
it('ignores a call without a session', () => {
const app = makeApp();
app._notePaneHistory(null, { paneHistoryLines: 0 });
expect(app._paneHistoryLines.size).toBe(0);
});
it('is dropped with the session', () => {
const app = makeApp();
app._notePaneHistory('s1', { paneHistoryLines: 0 });
app._clearHistoryTruncation('s1');
expect(app._paneHistoryLines.has('s1')).toBe(false);
});
});
describe('every terminal response feeds it (static guard)', () => {
it('the tab switch, the refresh and the history re-pull all record the pane', () => {
expect(methodSource(APP, 'selectSession')).toContain('this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);');
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* @fileoverview The kill path's waits end when the processes do, not on a timer.
*
* Closing a session used to sleep a fixed 100 + 200 + 100 ms across its signals
* and then verify in 100 ms steps, so a session whose processes were gone in a
* few ms still took ~0.45 s to close. `waitForProcessesExit()` keeps each of
* those deadlines but returns as soon as nothing is left running, and
* `isProcessRunning()` counts a zombie as exited (it holds nothing but its pid
* until its reaper gets to it, and `kill(pid, 0)` cannot tell it apart).
*
* Port: N/A.
*/
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { isProcessRunning, waitForProcessesExit } from '../src/utils/process-exit-wait.js';
describe('waitForProcessesExit', () => {
it('returns as soon as every pid has exited, long before the deadline', async () => {
let polls = 0;
const isRunning = (): boolean => ++polls < 4;
const started = Date.now();
const survivors = await waitForProcessesExit([101, 102], { timeoutMs: 5000, pollMs: 5, isRunning });
expect(survivors).toEqual([]);
expect(Date.now() - started).toBeLessThan(1000);
});
it('returns the survivors at the deadline instead of throwing', async () => {
const started = Date.now();
const survivors = await waitForProcessesExit([7, 8], {
timeoutMs: 60,
pollMs: 10,
isRunning: (pid) => pid === 8,
});
expect(survivors).toEqual([8]);
expect(Date.now() - started).toBeGreaterThanOrEqual(50);
});
it('does not wait at all when nothing is running', async () => {
const started = Date.now();
expect(await waitForProcessesExit([], { timeoutMs: 5000 })).toEqual([]);
expect(await waitForProcessesExit([9], { timeoutMs: 5000, isRunning: () => false })).toEqual([]);
expect(Date.now() - started).toBeLessThan(100);
});
});
describe('isProcessRunning', () => {
it('is true for a live process and false for a pid that does not exist', () => {
expect(isProcessRunning(process.pid)).toBe(true);
// Above the default pid_max on Linux and macOS alike.
expect(isProcessRunning(4_194_304 + 12_345)).toBe(false);
});
it.skipIf(process.platform !== 'linux')('counts a zombie as exited, which kill(pid, 0) cannot', async () => {
// `sleep 0` exits at once, and its parent then becomes `sleep 5`, which never
// reaps anything: the child stays a zombie until the parent itself goes.
const parent = spawn('sh', ['-c', 'sleep 0 & echo $!; exec sleep 5'], { stdio: ['ignore', 'pipe', 'ignore'] });
try {
const zombie = await new Promise<number>((resolve, reject) => {
parent.stdout.once('data', (chunk: Buffer) => resolve(parseInt(chunk.toString(), 10)));
parent.once('error', reject);
});
const state = (): string => {
const stat = readFileSync(`/proc/${zombie}/stat`, 'utf8');
return stat.charAt(stat.lastIndexOf(')') + 2);
};
for (let i = 0; i < 100 && state() !== 'Z'; i++) await new Promise((r) => setTimeout(r, 10));
expect(state()).toBe('Z');
expect(() => process.kill(zombie, 0)).not.toThrow();
expect(isProcessRunning(zombie)).toBe(false);
} finally {
parent.kill('SIGKILL');
}
});
});
+47
View File
@@ -946,6 +946,53 @@ describe('session-routes', () => {
expect(body.data.captureRows).toBeUndefined();
});
it('reports how many scrollback rows tmux holds for the captured pane', async () => {
// `truncated` measures the byte stream; this is what a `full=1` pull can
// return. 0 (a fullscreen CLI in the alternate screen) is the case where
// the client must not offer to load the rest.
harness.ctx._session.terminalBuffer = 'x'.repeat(4096);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(
_name: string,
opts?: { capturedGeometry?: { cols: number; rows: number }; capturedHistoryLines?: number }
) => {
if (opts) {
opts.capturedGeometry = { cols: 100, rows: 50 };
opts.capturedHistoryLines = 0;
}
return 'visible frame';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=1024`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.paneHistoryLines).toBe(0);
});
it('omits paneHistoryLines when the body is the byte history, not a capture', async () => {
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts) opts.capturedHistoryLines = 12;
return null;
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.paneHistoryLines).toBeUndefined();
});
// ── COD-47: full tmux scrollback replay on full page reload ──
it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => {
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
+92 -2
View File
@@ -16,6 +16,11 @@
* leaves the active-session handoff alone for a close this tab started. A delete
* from anywhere else still lands on the welcome screen.
*
* The close is also OPTIMISTIC: the tab goes and the next one is selected before
* the DELETE is even sent (the server's kill takes a few hundred ms, and a tab
* sitting there that long read as a dead button). A refused delete puts the row
* back where it was; one whose reply was lost is checked with a GET first.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts.
* Port: N/A.
*/
@@ -74,7 +79,11 @@ function makeApp(active: string | null, order = [A, B]): TestApp {
app.isSoloWindow = false;
app._wsSessionId = null;
app.terminal = { clear: vi.fn() };
app._apiDelete = vi.fn(async () => ({ success: true }));
// Shaped like the Response the real helper resolves with (or null: no answer).
app._apiDelete = vi.fn(async () => ({ ok: true, status: 200 }));
// The failure path's "is it still there?" check: yes, by default.
app._api = vi.fn(async () => ({ ok: true, status: 200 }));
app.saveSessionOrder = vi.fn();
// The real one touches ~20 maps; the parts this behavior depends on are the
// session map and the tab order, so those are pruned for real.
app._cleanupSessionData = vi.fn((id: string) => {
@@ -101,7 +110,7 @@ describe('closing the active session', () => {
// delete lands before the request resolves.
app._apiDelete = vi.fn(async () => {
app._onSessionDeleted({ id: A });
return { success: true };
return { ok: true, status: 200 };
});
await app.closeSession(A);
@@ -174,3 +183,84 @@ describe('closing the active session', () => {
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
});
});
describe('closing is optimistic', () => {
it('drops the tab and selects the next one BEFORE the server answers', async () => {
const app = makeApp(A);
let answer: (value: unknown) => void = () => {};
app._apiDelete = vi.fn(() => new Promise((r) => (answer = r)));
const closing = app.closeSession(A);
expect(app.sessions.has(A)).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
// Rendered now, not on the debounce that every session update restarts.
expect(app.renderSessionTabs).toHaveBeenCalledWith({ immediate: true });
expect(app.showToast).not.toHaveBeenCalled();
answer({ ok: true, status: 200 });
await closing;
expect(app.showToast).toHaveBeenCalledWith('Session closed and tmux killed', 'success');
});
it('a refused delete puts the row back where it was, and says so', async () => {
const app = makeApp(B, [A, B]);
app._apiDelete = vi.fn(async () => ({ ok: false, status: 500 }));
await app.closeSession(A);
expect(app._api).toHaveBeenCalledWith(`/api/sessions/${A}`);
expect(app.sessions.has(A)).toBe(true);
expect(app.sessionOrder).toEqual([A, B]);
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
});
it('a delete that landed but lost its reply counts as closed', async () => {
const app = makeApp(A);
app._apiDelete = vi.fn(async () => null);
app._api = vi.fn(async () => ({ ok: false, status: 404 }));
await app.closeSession(A);
expect(app.sessions.has(A)).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Session closed and tmux killed', 'success');
});
it('a 404 from the delete is a close that already happened', async () => {
const app = makeApp(A);
app._apiDelete = vi.fn(async () => ({ ok: false, status: 404 }));
await app.closeSession(A);
expect(app._api).not.toHaveBeenCalled();
expect(app.sessions.has(A)).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Session closed and tmux killed', 'success');
});
it('a session update arriving mid-close does not bring the tab back', async () => {
const app = makeApp(A);
app.updateCost = vi.fn();
app.updateSubagentParentNames = vi.fn();
app._apiDelete = vi.fn(async () => {
(app as unknown as { _onSessionUpdated: (d: unknown) => void })._onSessionUpdated({ id: A, status: 'idle' });
return { ok: true, status: 200 };
});
await app.closeSession(A);
expect(app.sessions.has(A)).toBe(false);
});
it('a second close of the same tab while the first is in flight is a no-op', async () => {
const app = makeApp(A);
let answer: (value: unknown) => void = () => {};
app._apiDelete = vi.fn(() => new Promise((r) => (answer = r)));
const first = app.closeSession(A);
await app.closeSession(A);
answer({ ok: true, status: 200 });
await first;
expect(app._apiDelete).toHaveBeenCalledTimes(1);
});
});
+61
View File
@@ -68,6 +68,7 @@ import * as fs from 'fs';
import * as fsPromises from 'node:fs/promises';
import { createInterface } from 'readline';
import { execSync } from 'child_process';
import { execFile as nodeExecFile } from 'node:child_process';
/**
* Flush the microtask queue to allow async scanForSubagents() to complete.
@@ -1628,6 +1629,66 @@ describe('SubagentWatcher', () => {
expect(result).toBe(true);
expect(completedHandler).toHaveBeenCalled();
});
it("killSubagentsForSession scans the process table ONCE for all of a session's agents", async () => {
// Closing a session ran a full `pgrep -f claude` + /proc read per recently
// active subagent (~85ms each on a busy box); one scan now serves them all.
// One readline per transcript: discovery reads each agent's file in turn.
const rls: ReturnType<typeof createMockRl>[] = [];
mockCreateInterface.mockImplementation(() => {
const rl = createMockRl();
rls.push(rl);
return rl;
});
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => {
if (path.includes('subagents')) return ['agent-k1.jsonl', 'agent-k2.jsonl', 'agent-k3.jsonl'];
if (path.includes('session1')) return ['subagents'];
if (path.includes('-home-user-project')) return ['session1'];
return ['-home-user-project'];
});
mockStatSync.mockReturnValue({ isDirectory: () => true, birthtime: new Date(), mtime: new Date(), size: 100 });
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
watcher.start();
for (let i = 0; i < 3; i++) {
await flushAsyncScan();
rls.forEach((rl) => rl.emit('close'));
await vi.advanceTimersByTimeAsync(100);
}
const agents = watcher.getSubagentsForSession('/home/user/project');
expect(agents.map((a) => a.agentId).sort()).toEqual(['k1', 'k2', 'k3']);
const execFileMock = vi.mocked(nodeExecFile) as unknown as Mock;
const originalExecFile = execFileMock.getMockImplementation();
let pgrepCalls = 0;
execFileMock.mockImplementation(
(cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string) => void) => {
if (cmd === 'pgrep') pgrepCalls++;
cb(null, '4242\n4343\n');
}
);
mockReadFile.mockImplementation(async (path: string) => {
if (path === '/proc/4242/environ') return 'HOME=/x\0PARENT=session1\0';
if (path.startsWith('/proc/')) return 'HOME=/x\0';
return mockReadFileSync(path);
});
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
let killCalls: unknown[][] = [];
try {
await watcher.killSubagentsForSession('/home/user/project', 'session1');
killCalls = [...killSpy.mock.calls];
} finally {
killSpy.mockRestore();
execFileMock.mockImplementation(originalExecFile as never);
}
expect(pgrepCalls).toBe(1);
expect(agents.every((a) => a.status === 'completed')).toBe(true);
// Only the matching process, and only once.
expect(killCalls).toEqual([[4242, 'SIGTERM']]);
});
});
describe('Error Handling', () => {
+14
View File
@@ -61,6 +61,20 @@ describe('generateClaudeMd', () => {
expect(result).toContain('CODEMAN_MUX=1');
});
it('should tell the agent to print absolute paths of files it creates', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('full absolute path');
expect(result).toContain('clickable');
});
it('should point the agent at the codeman skill', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('`codeman` skill');
expect(result).toContain('codeman skill install');
});
it('should include workflow rules', () => {
const result = generateClaudeMd('my-project');
+9 -7
View File
@@ -7,9 +7,9 @@
* pick that, with the grid open, would be refused (auto never collapses the
* grid) and leave nothing focused. So the fallback is grid-aware, and it lives
* IN closeSession: the delete broadcast routinely lands while the request is in
* flight, and the delete handlers skip ids in `_closingSessions`. The neighbour
* is captured BEFORE the await, like `wasActive`, because that broadcast may
* already have removed the tile.
* flight, and the delete handlers skip ids in `_closingSessions`. The close is
* optimistic, so the tile goes and the neighbour takes focus before the request
* is even sent; the broadcast then finds nothing left to do.
*
* - closing the focused tile: next tile in grid order, else the previous one;
* `s-other` is FIRST in sessionOrder and never tiled, so the old pick would
@@ -33,7 +33,8 @@ function setup(ids = IDS, focus = ids[0]) {
app.selectSession = vi.fn();
app.markIdleAlertSeen.mockClear();
let finish: () => void = () => {};
app._apiDelete = vi.fn(() => new Promise<void>((r) => (finish = r)));
// Resolves like the real helper's Response once `finish()` is called.
app._apiDelete = vi.fn(() => new Promise((r) => (finish = () => r({ ok: true, status: 200 }))));
// The real cleanup touches a lot of panels; what the fallback reads is the session list.
app._cleanupSessionData = vi.fn((id: string) => {
app.sessions.delete(id);
@@ -77,11 +78,12 @@ describe('closeSession on the focused tile', () => {
const { app, finish } = setup(IDS, 's-b');
const closing = app.closeSession('s-b');
await settle();
// The close already moved focus to the neighbour before the request went
// out; the broadcast for it must not move it again.
expect(app.activeSessionId).toBe('s-c');
app._onSessionDeleted({ id: 's-b' });
// Only the tile went; closeSession owns the follow-up (as the split's
// wrapper does for ids in _closingSessions), so focus has not moved yet.
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.activeSessionId).toBe('s-c');
expect(app.showWelcome).not.toHaveBeenCalled();
finish();
await closing;
+43 -1
View File
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } from '../src/tmux-manager.js';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
@@ -153,6 +153,48 @@ describe('the geometry a capture reports back', () => {
});
});
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
it('reads it from the one cursor query the capture already makes', () => {
// No extra tmux call: every capture is a synchronous exec on the server.
expect(methodBody).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'");
expect(methodBody.match(/display-message/g)).toHaveLength(1);
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
});
it('parses the fifth field as historyLines', () => {
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
cursorX: 3,
cursorY: 5,
cols: 120,
rows: 40,
historyLines: 812,
});
// A pane in the alternate screen (fullscreen claude) holds none.
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
});
it('never lets a missing or bad history field void the caret', () => {
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
expect(geometry && 'historyLines' in geometry).toBe(false);
}
});
it('still returns null when the geometry itself is bad', () => {
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
expect(
queryPaneCursor(() => {
throw new Error('no pane');
})
).toBeNull();
});
});
describe('why a capture has to report its height', () => {
it('a snapshot addresses rows the receiving terminal may not have', () => {
// formatPaneSnapshot positions every row absolutely. A terminal shorter
+65
View File
@@ -0,0 +1,65 @@
/**
* @fileoverview showToast() display time and drawer logging: a toast with no explicit
* `duration` uses the notification preference, an explicit `duration` (0 = sticky) still wins,
* and every toast is recorded in the notification drawer.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const windows: JSDOM[] = [];
function loadApp(notificationManager?: unknown) {
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
windows.push(dom);
const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
CodemanApp,
document: dom.window.document,
requestAnimationFrame: (cb: () => void) => cb(),
setTimeout,
clearTimeout,
console,
});
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(source, context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.notificationManager = notificationManager;
return app;
}
afterEach(() => {
vi.useRealTimers();
for (const dom of windows.splice(0)) dom.window.close();
});
describe('showToast', () => {
it('uses the configured display time when no duration is given', () => {
vi.useFakeTimers();
const app = loadApp({ getToastDurationMs: () => 10_000 });
app.showToast('hello');
expect(windows[0].window.document.querySelectorAll('.toast')).toHaveLength(1);
vi.advanceTimersByTime(9_000);
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
vi.advanceTimersByTime(1_500);
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
});
it('falls back to 3s without a notification manager', () => {
vi.useFakeTimers();
const app = loadApp();
app.showToast('hello');
vi.advanceTimersByTime(3_100);
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
});
it('lets an explicit duration of 0 stay until dismissed', () => {
vi.useFakeTimers();
const app = loadApp({ getToastDurationMs: () => 1_000 });
app.showToast('sticky', 'error', { duration: 0 });
vi.advanceTimersByTime(60_000);
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
});
});