Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82aeeaec02 | ||
|
|
ad57394618 |
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.41.0",
|
||||
"version": "1.40.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.41.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b3d3c64: 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.40.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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`. ⚠️ 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)
|
||||
**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)
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
<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> • <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||
</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.
|
||||
@@ -50,7 +54,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-20261010.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-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -422,7 +426,7 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
### Tab Alerts
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<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">
|
||||
</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.
|
||||
@@ -735,10 +739,6 @@ 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 |
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||
⭐ <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">
|
||||
</p>
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
|
||||
@@ -235,7 +235,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
|
||||
|
||||
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. ⚠️ **The close is optimistic**: `closeSession()` runs `_onSessionDeleted({ id })` itself (the split, tile, detached-window, WebSocket and per-session teardown, idempotent so the real broadcast finds nothing left), does the follow-up selection and renders the strip with `renderSessionTabs({ immediate: true })` BEFORE sending the DELETE; the server's kill takes a few hundred ms and a tab that sat there that long read as a dead button. `_onSessionCreated`/`_onSessionUpdated` ignore ids in `_closingSessions`, so no upsert brings the row back mid-request. `_apiDelete` resolves a Response (or `null`), never throws: OK or 404 is closed; anything else is checked with `GET /api/sessions/:id` (a delete can land and lose its reply, after its broadcast was already spent) and only a session that still exists gets its row back at its old index, with the error toast. Tests: `test/session-close-fallback.test.ts`.
|
||||
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`.
|
||||
|
||||
### Circuit breakers: Ralph and PTY-exit
|
||||
|
||||
@@ -811,7 +811,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame.
|
||||
|
||||
⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE it tears the tile down (like `wasActive`) and focuses it with `auto` before the delete request goes out; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead.
|
||||
⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead.
|
||||
|
||||
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
||||
|
||||
@@ -1071,8 +1071,6 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots.
|
||||
|
||||
⚠️ **The kill path's waits are deadlines, not sleeps** (`waitForProcessesExit()`, `utils/process-exit-wait.ts`): `Session.stop()` (PTY client, 100 ms), and `killSession()`'s children (200 ms), process group (100 ms) and final verify (2 s) each stop waiting as soon as the processes are gone. They used to be fixed sleeps plus a 100 ms verify poll, ~0.45 s per close even for a session whose processes died in a few ms. `isProcessRunning()` counts a zombie as exited (`/proc/<pid>/stat` state `Z`/`X`), since `kill(pid, 0)` cannot tell one apart and a zombie only awaits its reaper. Claude itself takes ~0.8 s to exit on SIGTERM or SIGHUP (measured 2026-10-10), so the process-group deadline still ends in SIGKILL for a claude pane, as it always did. `tmux kill-session` and the pane-pid lookup on this path are async, never `execSync` (tens of ms of a frozen server per close). `killSubagentsForSession()` makes ONE `getClaudePids()` scan for all of the session's active/idle subagents, never one `pgrep` per agent (~85 ms each with ~100 matching processes).
|
||||
|
||||
⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
|
||||
|
||||
## Local packages and build artifacts
|
||||
|
||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
@@ -99,6 +99,7 @@ every session or only the active tab.
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
@@ -146,11 +147,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
|
||||
### Notifications
|
||||
|
||||
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
|
||||
Master toggle, browser notifications, push subscription, audio alerts, 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Layout
|
||||
|
||||
@@ -73,6 +73,10 @@ precedence there.
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
|
||||
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
|
||||
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
|
||||
headers and the Run menus keep theirs.
|
||||
|
||||
**Status is carried by the dot and the tab's own styling:**
|
||||
|
||||
| Look | Meaning |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"version": "1.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"version": "1.40.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"version": "1.40.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.41.0",
|
||||
"version": "1.40.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -106,7 +106,6 @@ import {
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
resolveLocalShell,
|
||||
waitForProcessesExit,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -180,10 +179,7 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
|
||||
|
||||
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Graceful shutdown delay when stopping session (100ms) */
|
||||
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||
|
||||
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
||||
@@ -4805,10 +4801,8 @@ 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. 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 });
|
||||
// Give it a moment to terminate gracefully
|
||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||
|
||||
// Force kill with SIGKILL if still alive
|
||||
try {
|
||||
|
||||
@@ -761,35 +761,13 @@ export class SubagentWatcher extends EventEmitter {
|
||||
* by workingDir alone would kill subagents belonging to OTHER sessions.
|
||||
*/
|
||||
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
|
||||
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
|
||||
}
|
||||
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);
|
||||
}
|
||||
this.markSubagentAsCompleted(agent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,5 +56,3 @@ 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`.
|
||||
|
||||
@@ -94,13 +94,7 @@ import {
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
} from './docker-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
waitForProcessesExit,
|
||||
} from './utils/index.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
@@ -150,18 +144,12 @@ const TMUX_CREATION_WAIT_MS = 100;
|
||||
const GET_PID_MAX_RETRIES = 5;
|
||||
const GET_PID_RETRY_MS = 200;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** Delay after tmux kill command (200ms) */
|
||||
const TMUX_KILL_WAIT_MS = 200;
|
||||
|
||||
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
|
||||
/** Delay for graceful shutdown (100ms) */
|
||||
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;
|
||||
|
||||
@@ -2362,28 +2350,6 @@ 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.
|
||||
*/
|
||||
@@ -2638,9 +2604,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Check if a process is still alive
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -2650,9 +2614,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all PIDs are dead, returning as soon as they are
|
||||
// Verify all PIDs are dead, with retry
|
||||
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
||||
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
|
||||
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));
|
||||
if (stillAlive.length > 0) {
|
||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||
}
|
||||
@@ -2710,7 +2685,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.
|
||||
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
@@ -2727,7 +2702,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
|
||||
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
||||
|
||||
@@ -2749,9 +2724,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||
|
||||
childPids = await this.getChildPidsFresh(currentPid);
|
||||
for (const childPid of childPids) {
|
||||
@@ -2769,7 +2742,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
process.kill(-currentPid, 'SIGTERM');
|
||||
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
|
||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
process.kill(-currentPid, 'SIGKILL');
|
||||
}
|
||||
@@ -2778,12 +2751,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
@@ -2827,7 +2798,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Verify all processes are dead
|
||||
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
|
||||
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
||||
if (!allDead) {
|
||||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -2286,8 +2286,6 @@ 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)) {
|
||||
@@ -2312,9 +2310,6 @@ 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);
|
||||
@@ -5959,19 +5954,9 @@ class CodemanApp {
|
||||
// Session Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderSessionTabs({ immediate = false } = {}) {
|
||||
renderSessionTabs() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -6724,7 +6709,9 @@ class CodemanApp {
|
||||
// every agent CLI, claude included, shows its logo through PR #532's
|
||||
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
|
||||
// headers and the Run menus draw the same mark. An id with no logo rule (a
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
|
||||
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
|
||||
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
|
||||
const tabModeHtml = mode === 'shell'
|
||||
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
|
||||
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
|
||||
@@ -9654,32 +9641,27 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
// 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);
|
||||
// ⚠️ 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).
|
||||
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 {
|
||||
// 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 });
|
||||
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 });
|
||||
|
||||
if (wasActive && grid?.open) {
|
||||
// `auto`: the app chose this tile because the previous one went away.
|
||||
@@ -9704,50 +9686,18 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
this.renderSessionTabs({ immediate: true });
|
||||
this.renderSessionTabs();
|
||||
|
||||
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');
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
this.showToast('Failed to close session', 'error');
|
||||
} finally {
|
||||
this._closingSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -110,9 +110,6 @@ 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)
|
||||
|
||||
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
line1.appendChild(badge);
|
||||
} else {
|
||||
// The agent's logo: PR #532's slot, the mode id as data (an id with no
|
||||
// logo rule gets the slot's plain dot).
|
||||
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
|
||||
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
|
||||
const logo = document.createElement('span');
|
||||
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
|
||||
logo.setAttribute('aria-hidden', 'true');
|
||||
|
||||
@@ -369,6 +369,9 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -2216,6 +2216,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CLI Logos on Tabs</span>
|
||||
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out Button on Tabs</span>
|
||||
@@ -2723,26 +2730,6 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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; auto-closes after 8s by default, configurable per device in Settings → Notifications)
|
||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
*
|
||||
@@ -93,10 +93,6 @@ 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,
|
||||
@@ -171,24 +167,11 @@ 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();
|
||||
@@ -420,7 +403,7 @@ class NotificationManager {
|
||||
};
|
||||
|
||||
// Auto-close
|
||||
setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS));
|
||||
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
|
||||
}
|
||||
|
||||
async requestPermission() {
|
||||
@@ -483,7 +466,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)) {
|
||||
|
||||
@@ -5862,8 +5862,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* `duration` defaults to the "Toast display time" preference (3000ms unless changed in
|
||||
* Settings → Notifications) for every toast type. A message worth
|
||||
* `duration` defaults to 3000ms 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
|
||||
@@ -5875,7 +5874,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* regardless of duration.
|
||||
*/
|
||||
showToast(message, type = 'info', opts = {}) {
|
||||
const { duration = this.notificationManager?.getToastDurationMs?.() ?? 3000, action } = opts;
|
||||
const { duration = 3000, action } = opts;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
|
||||
@@ -487,6 +487,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
@@ -563,12 +564,6 @@ 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;
|
||||
@@ -2583,6 +2578,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
@@ -2673,8 +2669,6 @@ 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,
|
||||
@@ -2744,7 +2738,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
_version: 5,
|
||||
};
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.preferences = this.notificationManager.normalizePreferences(notifPrefsToSave);
|
||||
this.notificationManager.preferences = notifPrefsToSave;
|
||||
this.notificationManager.savePreferences();
|
||||
}
|
||||
|
||||
@@ -3541,6 +3535,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
showTabCliLogos: true,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
@@ -3677,6 +3672,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
|
||||
},
|
||||
|
||||
/**
|
||||
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
|
||||
* device). Anything but an explicit false reads as on, the same test the
|
||||
* pre-paint script in index.html applies, so a reload and a Save never
|
||||
* disagree about an odd stored value.
|
||||
*/
|
||||
tabCliLogosEnabled(settings) {
|
||||
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
@@ -3964,6 +3969,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
|
||||
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
|
||||
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
|
||||
// of `changed` below: the logo spans are always in the markup. It still
|
||||
// resizes every agent tab, which the tail of this function settles.
|
||||
const previousLogos = root.dataset.tabLogos;
|
||||
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
|
||||
root.dataset.tabLogos = logos;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
@@ -4013,6 +4026,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
} else if (previousLogos !== logos) {
|
||||
// A logo flip narrows or widens every agent tab with no render behind
|
||||
// it, so re-take what a render would have: the strip's one-row wrap
|
||||
// decision and the lines anchored to tab rects (lineage, subagent
|
||||
// connectors). A header that gains or loses a row resizes the terminal
|
||||
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
|
||||
this.updateTabOverflowMode?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
@@ -4261,7 +4282,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
@@ -3010,6 +3010,19 @@ body.solo-mode .btn-lifecycle-log {
|
||||
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
|
||||
colour lives here any more. Only the shell keeps a pill. */
|
||||
|
||||
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
|
||||
applyTabOrientation() and the pre-paint script in index.html stamp the
|
||||
attribute). The logo leaves the session tabs (header strip, vertical rail,
|
||||
sidebar, grouped rail, phone chips) and the desktop home rail; the status
|
||||
dot and the shell's SH pill stay. Every row it sits in spaces its children
|
||||
with flex `gap`, which skips a display:none item, so no empty slot is left.
|
||||
Tile and split headers (.tile-harness, .split-harness) and the Run menus
|
||||
keep their logos: only these two classes are named here. */
|
||||
html[data-tab-logos='off'] .session-tab .tab-harness,
|
||||
html[data-tab-logos='off'] .home-sessions-harness {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
|
||||
@@ -1391,6 +1391,13 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/**
|
||||
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
|
||||
* false hides the agent logo on session tabs and the desktop home rail
|
||||
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
|
||||
* and the Run menus keep their logos.
|
||||
*/
|
||||
showTabCliLogos: z.boolean().optional(),
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
@@ -1517,8 +1524,6 @@ 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(),
|
||||
|
||||
@@ -23,10 +23,6 @@ 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[] = [];
|
||||
@@ -52,10 +48,6 @@ 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',
|
||||
@@ -64,10 +56,6 @@ 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;
|
||||
@@ -78,7 +66,6 @@ function loadManager(
|
||||
}
|
||||
|
||||
win.eval(`
|
||||
window.escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
|
||||
${SOURCE}
|
||||
window.__testNotificationManager = NotificationManager;
|
||||
`);
|
||||
@@ -161,23 +148,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,6 @@
|
||||
* 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.
|
||||
*/
|
||||
@@ -79,11 +74,7 @@ function makeApp(active: string | null, order = [A, B]): TestApp {
|
||||
app.isSoloWindow = false;
|
||||
app._wsSessionId = null;
|
||||
app.terminal = { clear: vi.fn() };
|
||||
// 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();
|
||||
app._apiDelete = vi.fn(async () => ({ success: true }));
|
||||
// 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) => {
|
||||
@@ -110,7 +101,7 @@ describe('closing the active session', () => {
|
||||
// delete lands before the request resolves.
|
||||
app._apiDelete = vi.fn(async () => {
|
||||
app._onSessionDeleted({ id: A });
|
||||
return { ok: true, status: 200 };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
await app.closeSession(A);
|
||||
@@ -183,84 +174,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ 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.
|
||||
@@ -1629,66 +1628,6 @@ 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', () => {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* @fileoverview CLI Logos on Tabs (`showTabCliLogos`): the per-device switch
|
||||
* that hides the agent logo on the session tabs and the desktop home rail.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The switch is a row in App Settings → Appearance → Tabs, right after Tall
|
||||
* Tabs, and the REAL openAppSettings() / saveAppSettings() load and save it
|
||||
* by its id. The load/save contract is getElementById by id, so a renamed
|
||||
* control would otherwise just stop loading or saving, silently.
|
||||
* - It is modelled on tabTwoRows: a display key in the server-settings merge
|
||||
* (a phone never overwrites a desktop's choice) AND an optional boolean in
|
||||
* the .strict() SettingsUpdateSchema (the save PUTs it; a key the schema did
|
||||
* not declare would 400 the whole settings save).
|
||||
* - Default ON on every device; only an explicit false turns it off.
|
||||
* - The pre-paint script in index.html stamps `data-tab-logos` from the same
|
||||
* stored blob, so a reload never flashes the logos it is about to hide, and
|
||||
* a Save stamps it live through applyTabOrientation() without re-rendering
|
||||
* the tabs (the logo spans are always in the markup; CSS hides them).
|
||||
* - The CSS names exactly the tab logo and the home-rail logo. Tile and split
|
||||
* headers, the Run menus and the welcome launchers keep theirs.
|
||||
* - The row reads in Chinese under zh-CN.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss, { type Rule } from 'postcss';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const STYLES = read('styles.css');
|
||||
const MOBILE = read('mobile.css');
|
||||
const I18N = read('i18n.js');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
const PHONE_KEY = 'codeman-app-settings-mobile';
|
||||
|
||||
type Device = 'desktop' | 'mobile';
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
loadAppSettingsFromStorage(): Settings;
|
||||
getDefaultSettings(): Settings;
|
||||
tabCliLogosEnabled(s: Settings): boolean;
|
||||
applyTabOrientation(): void;
|
||||
_cachedAppSettings?: unknown;
|
||||
_apiPut: (path: string, body: Settings) => Promise<unknown>;
|
||||
_fullRenderSessionTabs: ReturnType<typeof vi.fn>;
|
||||
updateTabOverflowMode: ReturnType<typeof vi.fn>;
|
||||
_updateConnectionLinesImmediate: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods that run for real. Every other method of the app is a no-op here:
|
||||
* openAppSettings() and saveAppSettings() fan out into the voice, webhook,
|
||||
* tunnel, model and push panels, none of which this setting touches, and the
|
||||
* load/save lines under test sit between those calls.
|
||||
*/
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'tabCliLogosEnabled',
|
||||
'applyTabOrientation',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Methods the load/save paths call that other modules add to the prototype
|
||||
* (terminal-ui.js, panels-ui.js, session-ui.js), which this harness does not
|
||||
* load. Stubbed as own properties.
|
||||
*/
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(device: Device = 'desktop', stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot (new CodemanApp(): SSE,
|
||||
// timers, the terminal) never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => ${JSON.stringify(device)},
|
||||
isHandheldDevice: () => ${JSON.stringify(device)} !== 'desktop',
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
target._fullRenderSessionTabs = vi.fn();
|
||||
target.updateTabOverflowMode = vi.fn();
|
||||
target._updateConnectionLinesImmediate = vi.fn();
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const doc = win.document;
|
||||
const checkbox = () => doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const storedBlob = () =>
|
||||
JSON.parse(win.localStorage.getItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY) || '{}') as Settings;
|
||||
return { win, doc, app, checkbox, storedBlob };
|
||||
}
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const input = doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const row = input?.closest('.set-row') as HTMLElement;
|
||||
|
||||
it('is a switch in Appearance → Tabs (device scope), right after Tall Tabs', () => {
|
||||
expect(input?.type).toBe('checkbox');
|
||||
expect(input.parentElement!.matches('label.switch.switch-sm')).toBe(true);
|
||||
const group = row.closest('.set-group')!;
|
||||
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Tabs');
|
||||
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('device');
|
||||
expect(row.closest('.set-section')!.id).toBe('settings-appearance');
|
||||
expect(row.previousElementSibling!.querySelector('#appSettingsTabTwoRows')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says what it shows, what stays, and what it does not touch', () => {
|
||||
expect(row.querySelector('.set-row-label')!.textContent).toBe('CLI Logos on Tabs');
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
for (const part of ['logo', 'status dot', 'SH badge', 'Tiles', 'split headers', 'Run menus']) {
|
||||
expect(desc, part).toContain(part);
|
||||
}
|
||||
});
|
||||
|
||||
it('is found by the settings search for logo, icon, harness, agent, cli, tab and hide', () => {
|
||||
const words = row.getAttribute('data-search')!.split(/\s+/);
|
||||
for (const word of ['logo', 'icon', 'harness', 'agent', 'cli', 'tab', 'hide']) expect(words, word).toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load and save through settings-ui.js', () => {
|
||||
it('loads the stored value into the switch, and an absent key as on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{ showTabCliLogos: false }, false],
|
||||
[{ showTabCliLogos: true }, true],
|
||||
[{}, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot('desktop', stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('saves the switch to this device and sends it in the settings PUT, which the schema accepts', async () => {
|
||||
const { app, checkbox, storedBlob } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(storedBlob().showTabCliLogos).toBe(false);
|
||||
expect(app._apiPut).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = (app._apiPut as unknown as { mock: { calls: [string, Settings][] } }).mock.calls[0];
|
||||
expect(path).toBe('/api/settings');
|
||||
expect(body.showTabCliLogos).toBe(false);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('a Save stamps the attribute live', async () => {
|
||||
const { app, checkbox, doc } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
checkbox().checked = true;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device like tabTwoRows', () => {
|
||||
it('is a display key: a server value only seeds a device that has none', async () => {
|
||||
// Local choice made: the server's value (another device's) never replaces it,
|
||||
// while a synced key in the same payload does (the merge really ran).
|
||||
const kept = await boot('desktop', { showTabCliLogos: true, autoNameSessions: false });
|
||||
const merged = await kept.app.loadAppSettingsFromServer(
|
||||
Promise.resolve({ showTabCliLogos: false, autoNameSessions: true })
|
||||
);
|
||||
expect(merged.showTabCliLogos).toBe(true);
|
||||
expect(merged.autoNameSessions).toBe(true);
|
||||
|
||||
// A fresh device takes the server's value as its seed.
|
||||
const fresh = await boot('desktop');
|
||||
expect(
|
||||
(await fresh.app.loadAppSettingsFromServer(Promise.resolve({ showTabCliLogos: false }))).showTabCliLogos
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is an optional boolean in SettingsUpdateSchema, and nothing else passes', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: true }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: false }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({}).success).toBe(true);
|
||||
for (const bad of ['off', 'false', 0, 1, null, {}]) {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: bad }).success, JSON.stringify(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to on, on a desktop and on a phone', async () => {
|
||||
for (const device of ['desktop', 'mobile'] as const) {
|
||||
const { app, checkbox } = await boot(device);
|
||||
expect(app.tabCliLogosEnabled(app.loadAppSettingsFromStorage()), device).toBe(true);
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, device).toBe(true);
|
||||
}
|
||||
// The phone's defaults blob carries it explicitly, like its other tab keys.
|
||||
expect((await boot('mobile')).app.getDefaultSettings().showTabCliLogos).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off', async () => {
|
||||
const { app } = await boot();
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: false })).toBe(false);
|
||||
for (const value of [true, undefined, 'off', 0, null]) {
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: value }), String(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTabOrientation() stamps data-tab-logos without re-rendering', () => {
|
||||
async function settled(stored: Settings) {
|
||||
const booted = await boot('desktop', stored);
|
||||
booted.app.applyTabOrientation();
|
||||
for (const fn of [
|
||||
booted.app._fullRenderSessionTabs,
|
||||
booted.app.updateTabOverflowMode,
|
||||
booted.app._updateConnectionLinesImmediate,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
const apply = (next: Settings) => {
|
||||
booted.win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(next));
|
||||
delete booted.app._cachedAppSettings;
|
||||
booted.app.applyTabOrientation();
|
||||
};
|
||||
return { ...booted, apply };
|
||||
}
|
||||
|
||||
it('flips the attribute and leaves the tab markup alone', async () => {
|
||||
const { doc, app, apply } = await settled({ showTabCliLogos: true });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-measures the strip wrap and re-anchors the lines on a flip, and only on a flip', async () => {
|
||||
// Every agent tab just got narrower (or wider) with no render behind it: the
|
||||
// one-row wrap decision and the lines drawn from tab rects are stale.
|
||||
const { app, apply } = await settled({ showTabCliLogos: true });
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: true });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(2);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(2);
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pre-paint script in index.html', () => {
|
||||
// The layout one: there is an earlier inline script with the same opening.
|
||||
const PRE_PAINT =
|
||||
[...INDEX.matchAll(/<script>(try\{var m=window\.innerWidth[\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.find((src) => src.includes('dataset.tabStateOrder')) ?? '';
|
||||
|
||||
/**
|
||||
* Runs the real inline script in a fresh window as `device` (a phone is a
|
||||
* 393px viewport, the width test the script uses), with `blob` stored under
|
||||
* that device's key, or under `storeAs`'s key when given.
|
||||
*/
|
||||
function prePaint(blob: string | null, device: Device = 'desktop', storeAs: Device = device) {
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const win = dom.window as unknown as Window & { eval(src: string): void };
|
||||
Object.defineProperty(win, 'innerWidth', { value: device === 'desktop' ? 1440 : 393, configurable: true });
|
||||
if (blob !== null) win.localStorage.setItem(storeAs === 'desktop' ? DESKTOP_KEY : PHONE_KEY, blob);
|
||||
win.eval(PRE_PAINT);
|
||||
return win.document.documentElement;
|
||||
}
|
||||
|
||||
it('is found (the checks below are not vacuous)', () => {
|
||||
expect(PRE_PAINT).toContain('localStorage.getItem(k)');
|
||||
});
|
||||
|
||||
it('stamps off from the stored setting, on a desktop and on a phone', () => {
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false })).dataset.tabLogos).toBe('off');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile').dataset.tabLogos).toBe('off');
|
||||
});
|
||||
|
||||
it('leaves the logos on otherwise', () => {
|
||||
expect(prePaint(null).dataset.tabLogos).toBe('on');
|
||||
expect(prePaint('{}').dataset.tabLogos).toBe('on');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: true })).dataset.tabLogos).toBe('on');
|
||||
// A phone reads its own blob: a desktop's choice does not reach it.
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile', 'desktop').dataset.tabLogos).toBe('on');
|
||||
});
|
||||
|
||||
it('falls back to on, beside the other fallbacks, when the stored blob does not parse', () => {
|
||||
const root = prePaint('{not json');
|
||||
expect(root.dataset.tabLogos).toBe('on');
|
||||
expect(root.dataset.tabArrangement).toBe('classic');
|
||||
expect(root.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the CSS', () => {
|
||||
const rulesNaming = (css: string, pattern: RegExp) => {
|
||||
const found: Rule[] = [];
|
||||
postcss.parse(css).walkRules((rule: Rule) => {
|
||||
if (pattern.test(rule.selector)) found.push(rule);
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const selectorsOf = (rule: Rule) => rule.selectors.map((s) => s.replace(/\s+/g, ' ').trim());
|
||||
|
||||
it('hides the tab logo and the home-rail logo under the attribute, in one rule, and nothing else', () => {
|
||||
const rules = rulesNaming(STYLES, /data-tab-logos/);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(selectorsOf(rules[0])).toEqual([
|
||||
"html[data-tab-logos='off'] .session-tab .tab-harness",
|
||||
"html[data-tab-logos='off'] .home-sessions-harness",
|
||||
]);
|
||||
const decls: string[] = [];
|
||||
rules[0].walkDecls((d) => {
|
||||
decls.push(`${d.prop}: ${d.value}${d.important ? ' !important' : ''}`);
|
||||
});
|
||||
expect(decls).toEqual(['display: none']);
|
||||
expect(rules[0].parent?.type).toBe('root');
|
||||
});
|
||||
|
||||
it('leaves the tile and split headers, the Run menus and the welcome launchers their logos', () => {
|
||||
const selectors = rulesNaming(STYLES, /data-tab-logos/)
|
||||
.flatMap(selectorsOf)
|
||||
.join(' ');
|
||||
for (const other of ['tile-harness', 'split-harness', 'run-mode-option', 'welcome', 'mobile-overview']) {
|
||||
expect(selectors, other).not.toContain(other);
|
||||
}
|
||||
// Not the shared slot either: the Run menus draw the same `.run-mode-dot`.
|
||||
expect(selectors).not.toMatch(/(^|\s)\.run-mode-dot/);
|
||||
});
|
||||
|
||||
it('nothing on a phone or in a skin brings the logo back over the rule', () => {
|
||||
expect(MOBILE).not.toContain('data-tab-logos');
|
||||
for (const css of [STYLES, MOBILE]) {
|
||||
for (const rule of rulesNaming(css, /tab-harness|home-sessions-harness|run-mode-dot/)) {
|
||||
rule.walkDecls('display', (d) => {
|
||||
// The only other display rule on the logo is the compact rail's rename
|
||||
// rule, which hides it too.
|
||||
expect(d.value, rule.selector).toBe('none');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh-CN', () => {
|
||||
const rowText = (doc: Document) => {
|
||||
const row = doc.getElementById('appSettingsShowTabCliLogos')!.closest('.set-row')!;
|
||||
return {
|
||||
label: row.querySelector('.set-row-label')!.textContent!.trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
};
|
||||
const english = rowText(new JSDOM(INDEX).window.document);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: { start(): void; configure(o: object): void } }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const chinese = rowText(dom.window.document);
|
||||
/** What may stay Latin: the CLI and SH names. */
|
||||
const leftover = (text: string) => text.replace(/\b(CLI|SH|Shell)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it('translates the label and the description, with no English left', () => {
|
||||
expect(chinese.label).toBe('标签页上的 CLI 图标');
|
||||
expect(chinese.description).not.toBe(english.description);
|
||||
expect(leftover(chinese.label)).toEqual([]);
|
||||
expect(leftover(chinese.description)).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const key of [english.label, english.description]) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
expect(I18N.match(new RegExp(`^\\s*(?:'${escaped}'|"${escaped}"):`, 'gm')) ?? [], key).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -61,20 +61,6 @@ 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');
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
* 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.
|
||||
*
|
||||
* - 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,8 +33,7 @@ function setup(ids = IDS, focus = ids[0]) {
|
||||
app.selectSession = vi.fn();
|
||||
app.markIdleAlertSeen.mockClear();
|
||||
let finish: () => void = () => {};
|
||||
// Resolves like the real helper's Response once `finish()` is called.
|
||||
app._apiDelete = vi.fn(() => new Promise((r) => (finish = () => r({ ok: true, status: 200 }))));
|
||||
app._apiDelete = vi.fn(() => new Promise<void>((r) => (finish = r)));
|
||||
// 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);
|
||||
@@ -78,12 +77,11 @@ 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-c');
|
||||
expect(app.activeSessionId).toBe('s-b');
|
||||
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await closing;
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||