diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index a8e05691..0abed04d 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -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.40.0",
+ "version": "1.41.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index fab29389..02874dd1 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
-If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
+If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
diff --git a/.gitignore b/.gitignore
index 5113ac0a..93003107 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,7 +107,9 @@ todo.md
@fix_plan.md
readme-preview.mjs
-# Uploaded images land here under each session working dir (runtime artifact)
+# Prompt uploads land here under each session working dir (runtime artifact);
+# .claude-images/ is where they landed before the move.
+.codeman-uploads/
.claude-images/
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
diff --git a/AGENTS.md b/AGENTS.md
index e0cf802b..a3b084da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/.test.ts` for one file
-- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
+- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fe308d5..4c5b7af7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,51 @@
# aicodeman
+## 1.41.0
+
+### Minor Changes
+
+- 87f1c9c: ### Thanks
+ - @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
+ - @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
+ - @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
+ - @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
+
+ 
+
+ **Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
+
+ **Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
+
+ **The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
+
+ **`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
+
+ **Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
+
+ **MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
+
+ **Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
+
+ **CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
+
+ **Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
+
+ **Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
+
+ **Fixes.**
+ - **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
+ - **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
+ - **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
+ - **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
+ - **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
+ - **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
+ - **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
+ - **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
+
+ **For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
+
+ **Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
+
## 1.40.0
### Minor Changes
diff --git a/CLAUDE.md b/CLAUDE.md
index d122f877..15d32bfc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -78,7 +78,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
-**Version**: 1.40.0 (must match `package.json`)
+**Version**: 1.41.0 (must match `package.json`)
## Project Overview
@@ -200,6 +200,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
+**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
+
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
@@ -246,9 +248,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
-**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 `.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`
+**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. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ 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 `.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`, and `COPILOT_HOME` for Copilot), 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-` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
+**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w-` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:"` (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)
@@ -278,7 +280,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
-**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
+**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
@@ -286,7 +288,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
-**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) pages the wheel and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
+**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
@@ -295,6 +297,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
+**Prompt uploads** (`POST /api/sessions/:id/paste-image`; client wiring in `image-input.js` and `keyboard-accessory.js`): a pasted file is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
+
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `
` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `` outside 1-1048576 and an `xl/workbook.xml` `` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/.`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
@@ -329,11 +333,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
-**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
+**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
-**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `
-
- ⭐ Like Codeman? Give it a star on GitHub! It takes one click and helps more people find the project. ⭐
-
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
@@ -54,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
-
+
---
@@ -426,7 +422,7 @@ The title is templated into the served HTML on first byte, so it's correct from
### Tab Alerts
-
+
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
@@ -739,6 +735,10 @@ For AI agents and automation that control Codeman without a browser: an agent th
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
+
+
+
+
#### Step 1: install it
| How | Command | Scope |
@@ -944,6 +944,24 @@ codeman tui --list # numbered session list (plain tex
codeman tui 3 # attach to session 3 of that list
```
+### `codeman agent` — session-to-session verbs in every CLI mode
+
+The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
+
+```bash
+codeman agent ls # sessions; * marks this one
+SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
+codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
+codeman agent read "$SID" # last answer (as the server reads it for that mode)
+codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
+codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
+codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
+codeman agent interrupt "$SID" # a bare ESC, conversation intact
+codeman agent rm "$SID" # any session except this one
+```
+
+Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
+
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e1211244..32d4690e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -24,11 +24,7 @@
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
diff --git a/config/test-suites.ts b/config/test-suites.ts
index 6283d361..d005f0aa 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -22,6 +22,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
+ 'test/tab-rail-search.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -45,6 +46,7 @@ export const BROWSER_TEST_GLOBS = [
'test/spreadsheet-preview.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
+ 'test/markdown-anchor-links.browser.test.ts',
];
/**
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 47c22e0b..9a53134e 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -87,6 +87,93 @@ the HTTP status.
Adding a new error code is non-breaking; removing or renaming one is a major change.
+## Cron jobs
+
+Saved jobs and their launch history are separate from the legacy `/api/scheduled`
+duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
+is the unversioned alias. These routes use the response envelope above; the table
+lists the value inside `data` on success.
+
+| Method | Path | Request body | Response `data` |
+| --- | --- | --- | --- |
+| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
+| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
+| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
+| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
+| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
+| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
+| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
+| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
+| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
+
+### Job request fields
+
+The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
+`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
+are required according to the selected prompt and schedule:
+
+| Field | Type / validation |
+| --- | --- |
+| `name` | String, 1–200 characters |
+| `agentType` | A supported session mode (including `shell`) |
+| `workingDir` | Existing, allowed working-directory path |
+| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
+| `promptMode` | `inline_text` or `prompt_file_path` |
+| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
+| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
+| `inputMode` | `paste` or `typed` |
+| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
+| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
+| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
+| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
+| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
+| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
+| `enabled` | Boolean |
+| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
+| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
+| `notes` | Optional string, at most 2000 characters |
+
+`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
+When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
+`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
+policy, and does not change the schedule. `activeAgents` counts live sessions of
+the same agent type, excluding sessions created by this job.
+For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
+also closes the previous run's session before launching, even if it is still working.
+
+### Job and run response fields
+
+`CronJob` contains the request fields plus server-maintained `id`, optional
+`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
+`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
+milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
+`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
+
+`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
+`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
+`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
+Run times are also Unix milliseconds. Status is one of `created`,
+`session_started`, `prompt_sent`, `failed`, or `skipped`.
+
+Prompt delivery continues asynchronously after session launch, so `Run Now` can
+return `session_started` before the prompt is sent. Read run history for subsequent
+updates, but do not assume a terminal status will follow: if the session is closed
+during the readiness wait or the server restarts before delivery, the run can remain
+`session_started` indefinitely with `finishedAt: null`.
+`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
+of the agent's task**; `prompt_sent` does not prove that the task succeeded.
+
+In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
+or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
+`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
+launch-command job without the required privilege grant. Invalid definitions or
+working directories return `INVALID_INPUT`; launch/delivery failures are recorded
+on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
+
+See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
+See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
+`cron:runUpdated` SSE events.
+
## Long-polling (agent wait)
Three calls block until something happens instead of answering immediately. They
@@ -466,6 +553,27 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
+## The `codeman agent` CLI (client over these endpoints)
+
+`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
+
+## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
+
+A `multipart/form-data` body with one `image` part. The file is written into the
+session's workspace as `/.codeman-uploads/paste--.`, and
+`data` carries `path` and `filename` for the client to type the path into the
+prompt. The folder is Codeman's own: hidden, created on first use with a
+`.gitignore` containing `*` (written once, never over a file already there), and
+cleaned up the way pasted images always were: `paste-*` files older than 7 days
+go in an hourly sweep, and the folder goes when the last session of that
+workspace is killed. Uploads made before this release sit in `.claude-images/`;
+that folder receives nothing new, and is swept and removed the same way for one
+release. A remote (SSH) session answers 400, since the file would land on the
+Codeman host under a path the remote agent cannot read. A Docker session of an
+owned case is fine, its workspace is bind-mounted at the same absolute path; an
+adopted container (`owned: false`) mounts nothing, so its agent can open the file
+only if the container itself exposes that host path.
+
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
@@ -812,10 +920,10 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
Result (`data`):
- `applied` — `false` for the dry run.
-- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
+- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- - `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
+ - `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
index 33775cc5..9aeb8c03 100644
--- a/docs/architecture-invariants.md
+++ b/docs/architecture-invariants.md
@@ -225,7 +225,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect`
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
-**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ These gesture rules (first-event page, notch accumulation, prepaid-debt reset, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
+**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ These gesture rules (first-event page, notch accumulation, prepaid-debt reset, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
@@ -235,7 +235,7 @@ Further detail: the `: ` 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-` 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. 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. ⚠️ **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`.
### Circuit breakers: Ralph and PTY-exit
@@ -254,7 +254,7 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne
- **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay.
- **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand.
-Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.claude-images` while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
+Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.codeman-uploads` (and the pre-move `.claude-images`) while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
### Dead-pane respawn: the resume pin
@@ -306,7 +306,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing.
- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`).
-- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row.
+- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. The one exception is the rail's **Search sessions** box (#580): while it holds text `_projectTabGroups()` passes `collapsedGroupIds: []`, so every group is DRAWN open and a match inside a collapsed one can show, and `toggleTabGroupCollapsed()` refuses; the stored set and its localStorage key are never written, so clearing the search puts the collapse back exactly as it was (see Session list layout, the rail search).
- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible.
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
@@ -351,7 +351,7 @@ The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings →
⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up.
-⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's `.claude-images`.
+⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's upload dirs (`.codeman-uploads`, and the pre-move `.claude-images`).
⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`.
@@ -728,7 +728,7 @@ The guard is a one-shot flag on each trap rather than a browser test or a readin
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
-Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones.
+Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones. Apply (`applyAppSettings()`) sits beside Save in the same actions and saves without closing; it only sets a one-shot flag that `saveAppSettings()` consumes before its first await, so a Save clicked mid-Apply still closes the sheet.
**The rail means two different things, and that is deliberate:**
@@ -793,6 +793,20 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the closed drawer keeps `display: flex`, so without `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable.
+⚠️ **The rail's Search sessions box and the sidebar's filter box are ONE row filter** (#580): `applySidebarFilter(query)` sets `_sidebarFilter`, the rail box (`setTabRailSearch`, `#tabRailSearch` at the top of `#tabRail`) sets `_tabRailSearch`, and both run `_applyTabListFilter()`, which reads the rows the last render drew and applies the pure `CodemanTabSearch` (constants.js) as classes. Only one needle is ever live, because the rail and the sidebar never host `#sessionTabs` at the same time (`applyTabOrientation()` forces horizontal while the sidebar owns the list); off the rail and out of a reachable sidebar the needle is empty and every class comes off. The rules that keep it a view filter:
+
+- **Classes only.** `tab-filtered-out` on a row, and on a `.tab-layout-group` or `.tab-cluster` the filter emptied; never a reorder, a re-render per keystroke, a layout write or a request, so `sessionOrder`, the Alt+N badges, the server order and the tab layout never move. Both render paths end in `applySidebarFilter()`, which re-applies it. The hide rules are layout-scoped like the sidebar's original one (`html[data-tab-orientation='vertical'] .tab-rail ...` for rows, groups and case boxes; `html[data-session-list="sidebar"] ...` for rows and case boxes), so a leaked class can never hide anything on the header strip, which has no box to clear it from.
+- **Matching.** The rail matches the NAME only (`.tab-name`'s `data-full-name`, a web tab's title, never a URL or folder); the sidebar keeps its name + working directory. Trimmed, case-insensitive substring, lower-cased with `toLowerCase()`, never `toLocaleLowerCase()` (a Turkish locale lowers "API" to "apı").
+- **A row with a tab alert is never hidden** (owner decision on #580, the collapsed-header promise again: a prompt waiting on you is never hidden by a view filter). The row goes into the core as `keep: true` when `tabAlerts` holds anything for it (red `action` or yellow `idle`); a kept row counts toward its section, so its group or case box stays on screen, but not toward `matchCount`, so "No sessions match" can show above a lone alerted row. Alerts come and go through `renderSessionTabs()`, whose tail re-runs the filter, so nothing else has to.
+- **Counts and the tree.** A section's count shows the rows left showing during a search and is restored from the `data-total` it stashed on first touch. In the grouped tree `_tabTreeItems()` skips a hidden group whole (header included), the filter tail re-runs `_applyTabTreePositions()` and moves the one roving stop off a hidden item, but only while a search hides something or right after one changed what shows (both render paths already set them over an unfiltered tree). Only the grouped rail is a tree; the flat rail stays a tablist.
+- **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes.
+- **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects.
+- **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler.
+- **Drag stays on while searching** (owner decision): searching for a tab and dragging it into a group is a useful flow. A drop uses the same anchor logic relative to the visible rows, so a row can land between visible neighbours with hidden rows on either side.
+- **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`.
+
+Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.test.ts` (browser suite, not in the gate; it installs the real `setupEventListeners()` so the capture-before-inline Escape order is the shipped one).
+
⚠️ **The detailed RAIL additionally wears the home rail's CARD, and the detailed SIDEBAR deliberately does not**: the rail is an occasional, resizable list you scan, the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall — so the card rules are RAIL-SCOPED and were NOT added to the comma-grouped selectors above, which is the one-line change that would silently restyle the sidebar. Card state accents reuse `home-sessions-blink-red`/`-yellow` rather than declaring a second pair, and every state dot rule excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and the rail's are (0,5,1)+. There is no `.active` rule in that block on purpose: `.session-tab.active` already paints border/background/box-shadow `!important`.
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
@@ -805,17 +819,17 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
### Tile grid
-**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
+**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives, reports the cells whose sessions went away as `freed`), `restoreTileGridCells` (a stored grid as it comes back), `rankTileSessions` (the order a fresh or filling grid takes sessions in), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
⚠️ **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=`, 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 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.
+⚠️ **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.
⚠️ **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.
-⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
+⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, EXACTLY as the user left it (decision 11: `restoreTileGridCells` keeps its cells and holes, its focus and a zoom the user chose; `_openStoredTileGrid` puts back its divider sizes and count; an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), never filled to the remembered count and never trimmed to the window (`_applyTileLayout`'s automatic zoom shows the focused tile until the window fits, the arrangement kept); (b) else an open split's two sessions; (c) else the open sessions as `rankTileSessions` orders them (`_tileGridRanking`, tile-grid.js: no detached ones; WORKING first, the most recently started turn first, keyed off `lastSubmitAt` ONLY because a working pane's `lastActivityAt` is always "now"; then the ones NEEDING INPUT, `needs`/`waiting`, the red and yellow tab alerts, most recent first; then the rest by `sessionActivityAnchor`, newest first; a 0 stamp last, tab order the final tiebreak; the states are `_mobileOverviewState`'s, and a page without it falls back to tab order), the active session always included and focused; then `tileGridSetForCount` trims (b) and (c) from the end (the session to focus kept) or fills them from the ranking to the remembered count (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`). ⚠️ A stored grid's freed cells (a session gone or popped out since) are filled from the ranking first, then other empty cells, only while it holds fewer tiles than its stored `count`; a hole the user made stays. A count picked in the menu with the grid closed (`_activateTileGrid({ count })`) re-forms a stored grid to it (`reformTileCells`, the added tiles in the empty cells first). Ctrl/Cmd+click with the grid closed opens what the toggle would with the clicked session focused, never past the count: it joins while the grid holds fewer, else takes the last tile's place. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
@@ -825,7 +839,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
-⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants; a restore puts the holes back only when the cell count matches the current shape, else the tiles stay packed, and `_closeStoredTileGrid` writes the cells back, never the packed list. The old packed format reads as cells with no hole; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
+⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
### Gesture control: the setting
@@ -877,7 +891,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
### Entrance animations
-**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
+**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section.
⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart.
@@ -889,6 +903,14 @@ Tests: `test/terminal-touch-tap.test.ts`.
⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target.
+**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`.
+
+⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both.
+
+⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible.
+
+⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says.
+
### Mobile tab strip scrolling
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
@@ -1063,6 +1085,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
+**Prompt uploads live in `/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`.
+
### Process-tree walks are bounded
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path).
@@ -1071,6 +1095,8 @@ 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//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
diff --git a/docs/browser-testing-guide.md b/docs/browser-testing-guide.md
index 405ef5f5..117d8335 100644
--- a/docs/browser-testing-guide.md
+++ b/docs/browser-testing-guide.md
@@ -77,11 +77,13 @@ A test that starts a server binds an ephemeral port, never a fixed one:
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
- any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
- excepted).
-- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
-- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
- convention in `test/mobile/README.md` for now.
+ any `WebServer` built under `test/` on a non-zero port.
+- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
+ `address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
+- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
+ binds an ephemeral port too; read it from `server.boundPort`.
+- The one exception is `test/codex-predictive-echo.test.ts`, which starts a separate lab
+ server process on port 3222 that the guard cannot see.
- Never port 3000: that is the live instance.
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
diff --git a/docs/cli-registry.md b/docs/cli-registry.md
index 10e49c6b..247e6a32 100644
--- a/docs/cli-registry.md
+++ b/docs/cli-registry.md
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
-- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
+- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
@@ -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
@@ -265,9 +265,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
-`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
+`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
-`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `/`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
+**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
+
+`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `/`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
diff --git a/docs/cron-guide.md b/docs/cron-guide.md
index a865196b..92ba3320 100644
--- a/docs/cron-guide.md
+++ b/docs/cron-guide.md
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
-- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
+- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
### In the browser
-1. Click **⏰ Cron** in the header.
+1. Click **⏰ Cron** in the bottom toolbar.
2. Click **+ New Job**.
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
**prompt** (inline text or a file path), pick a **schedule**, and leave
diff --git a/docs/images/codeman-skill-20261010.gif b/docs/images/codeman-skill-20261010.gif
new file mode 100644
index 00000000..9da1963d
Binary files /dev/null and b/docs/images/codeman-skill-20261010.gif differ
diff --git a/docs/images/codeman-skill-20261010.png b/docs/images/codeman-skill-20261010.png
new file mode 100644
index 00000000..26ff2f96
Binary files /dev/null and b/docs/images/codeman-skill-20261010.png differ
diff --git a/docs/images/codeman-skill-crt-20261010.gif b/docs/images/codeman-skill-crt-20261010.gif
new file mode 100644
index 00000000..0ed33092
Binary files /dev/null and b/docs/images/codeman-skill-crt-20261010.gif differ
diff --git a/docs/images/codeman-skill-crt-20261010.png b/docs/images/codeman-skill-crt-20261010.png
new file mode 100644
index 00000000..276a727b
Binary files /dev/null and b/docs/images/codeman-skill-crt-20261010.png differ
diff --git a/docs/images/codeman-tab-states-20261010.gif b/docs/images/codeman-tab-states-20261010.gif
new file mode 100644
index 00000000..93bacc53
Binary files /dev/null and b/docs/images/codeman-tab-states-20261010.gif differ
diff --git a/docs/images/codeman-tab-states-20261010.png b/docs/images/codeman-tab-states-20261010.png
new file mode 100644
index 00000000..2d848ed3
Binary files /dev/null and b/docs/images/codeman-tab-states-20261010.png differ
diff --git a/docs/images/codeman-tour-20261010.png b/docs/images/codeman-tour-20261010.png
new file mode 100644
index 00000000..cdeae31f
Binary files /dev/null and b/docs/images/codeman-tour-20261010.png differ
diff --git a/docs/images/tiles-crt-20261010.gif b/docs/images/tiles-crt-20261010.gif
new file mode 100644
index 00000000..de5cd335
Binary files /dev/null and b/docs/images/tiles-crt-20261010.gif differ
diff --git a/docs/images/tiles-crt-stats-20261010.gif b/docs/images/tiles-crt-stats-20261010.gif
new file mode 100644
index 00000000..d8caa519
Binary files /dev/null and b/docs/images/tiles-crt-stats-20261010.gif differ
diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md
index a9ae64a2..d3711c80 100644
--- a/docs/tile-grid-plan.md
+++ b/docs/tile-grid-plan.md
@@ -45,20 +45,28 @@ or settled a question the spec left open. The invariants as built are in
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
compare with the last English value, not the translated DOM.
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
- decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
- count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
- (constants.js) picks the grid this tab last had, else an open split's two sessions, else
- the open sessions in tab order, the active one always included and focused, and
- `tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
- (from tab order) to the count. A remembered grid comes back with its tiles first, in
- their cells, then sessions in tab order, to the count in total: the count is a shape
- change under the cell model's rule (`reformTileCells`: the tiles keep their row and
- column when all fit, else they pack in reading order) and the added tiles fill the empty
- cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
- remembered grid still wins over an open split: the split closes and its sessions are not
- seeded first. A page-load restore brings back exactly the stored grid, whatever the
- count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
- session among them and focused (owner answer: N, not N+1).
+ decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same
+ `toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left
+ it: which session sits in which cell, holes included, its tile count, divider sizes,
+ focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never
+ filled to the remembered count and never trimmed to the window (a window too small
+ for it shows the focused tile alone until it fits, the arrangement kept). A session
+ that no longer exists frees its cell, which the ranking fills. Only with nothing
+ stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an
+ open split's two sessions, else the open sessions as `rankTileSessions` orders them
+ (owner request: "prefer to load in tiles that are working and then the most recent,
+ so the oldest don't get opened"): WORKING first (the most recently started turn
+ first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow
+ tab alerts), then the rest by most recent activity, tab order breaking ties; the
+ active one always included and focused, and `tileGridSetForCount` trims it (from the
+ end, the session to focus kept) or fills it (from the ranking) to the remembered count
+ (default 6, at most what the window fits). The states and stamps are the home
+ screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still
+ wins over an open split: the split closes and its sessions are not seeded first. A
+ page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab
+ with the grid closed opens what the toggle would with that session among the tiles and
+ focused, never past the count (owner answer: N, not N+1): it joins the first empty cell
+ while the grid holds fewer than the count, else it takes the last tile's place.
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
to right click over the tile button to adjust it"). It replaces the button's native title:
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
@@ -80,9 +88,10 @@ or settled a question the spec left open. The invariants as built are in
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
element close it (the single view a close starts focuses its terminal when its replay
lands; a menu left open behind that would send its keys there). A pick is remembered per
- device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
- tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
- stays, the others leave from the end or join from tab order, filling empty cells first,
+ device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to
+ it, its tiles first in their cells); with the grid open it re-forms it
+ (`_reformTileGrid`): the focused tile always stays, the others leave from the end or
+ join from the ranking, filling empty cells first,
every joining tile mounted and laid out before any connects (one fit, one PTY resize
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
"Open group as tiles", Run) still add up to the cap of 6.
@@ -97,7 +106,10 @@ or settled a question the spec left open. The invariants as built are in
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
- own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
+ own `settle` style, the default of App Settings → Animations → Tile Animations, which
+ switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
+ `beam`, ...; docs/architecture-invariants.md#entrance-animations).
+ Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
@@ -389,16 +401,34 @@ the harness/model request; see "As built")
### Persistence
-Decided: per device, restored on reload. Stored in localStorage key
-`codeman:tile-grid`:
+Decided: per device (per browser), restored on reload when it was open, and by
+the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
+server:
```json
-{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
- "colFr": [1, 1, 1], "rowFr": [1, 1] }
+{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
+ "zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
```
-Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
-duplicate ids on load. Never restored in a solo window.
+Session ids and the layout, never content. `ids` are the CELLS in reading order,
+`null` for an empty one. `count` is how many tiles the user's own last change
+left (open, add, remove by hand, a count picked): a session that goes away by
+itself (deleted, popped out, its socket refused) does not lower it, so the next
+time the grid opens the ranking fills that place, while a hole the user made
+stays. It is written on every change (a move, a divider drag at pointer-up, a
+tile added or removed, a count picked, a focus, a zoom) and kept, as
+`open: false`, however the grid closes: the toggle, a non-tiled tab,
+`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still
+frees its cell), Home, the width gate, the last tile, "Open group as tiles",
+closing or killing sessions. Nothing is written while a stored grid is being
+put back, so a half-built grid never overwrites it.
+
+A pure sanitizer drops unknown, deleted, detached and duplicate ids on load,
+reports the cells their sessions freed (`freed`), and derives `count` for a
+value written before it existed (the number of sessions the cells name); the
+old packed `ids` (no nulls) read as cells with no hole, and anything that is not
+a v1 object is ignored. The format stays `v: 1`, so an older build still reads
+a newer value (it ignores `count`). Never read or written in a solo window.
The restore runs INSIDE `handleInit`, in place of its initial
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
@@ -409,6 +439,17 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
against the live list without rebuilding tiles that are still alive.
+A cell freed since the grid was stored is filled during that restore, from a
+ranking that knows each session's status and stamps (the init payload) but not
+yet its pending approvals: `seedApprovals` asks the server for them
+asynchronously, and the restore has run by the time they land. So on a reload a
+session waiting on a permission dialog or an unseen finished turn ranks with the
+quiet ones for that one fill (working sessions still rank first). Accepted: a
+fill held back for the approvals would open fewer tiles, which can be another
+shape, and then reshape the grid and move the user's tiles a second after the
+reload; so approvals that land later never re-form a restored grid. The Tiles
+toggle, run once the page has loaded, ranks with them.
+
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
@@ -815,7 +856,8 @@ Separate follow-up PRs worth doing (see "Follow-ups").
open, and an open menu owns the Escape (it closes alone and the keyboard goes
back to the Tiles button, like the tab-group menu).
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
-- **No secrets in localStorage**: the stored grid holds ids only.
+- **No secrets in localStorage**: the stored grid holds session ids and its
+ layout only, never content.
- **Memory**: everything a tile creates is released in `destroy()`.
## Delivery: two PRs
@@ -1008,13 +1050,17 @@ exits green. Use the browser runner for those files and read the file count.
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
-4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
- a "Load full history" action inside a tile. (Done since: a tile pages a
- hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
- #555 route, and hand-reports a plain click while its session has
- `cliMouseTracking` on, both through the primary pane's gates aimed at the
- tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
- tile leaves the wheel to xterm.)
+4. Tile parity extras: a "Load full history" action inside a tile. (Done
+ since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
+ the primary pane's #555 route, hand-reports a plain click while its session
+ has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
+ renderer as SGR wheel reports from its own cells
+ (`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
+ pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
+ pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
+ the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
+ scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
+ since xterm turns it into a horizontal no-op off macOS.)
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
@@ -1053,7 +1099,8 @@ exits green. Use the browser runner for those files and read the file count.
is on right-click of the button (its title says so, as do the wiki and the
Help modal). Superseded in part by decision 10: right-click is now the count
menu, and a remembered grid is filled to the count instead of opening
- exactly as stored.
+ exactly as stored; decision 11 then restored "exactly as stored" and put a
+ ranking in place of the tab order.
9. **No + in the tile header.** Decided by the owner ("remove the + button from
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
session in this case" went with it. Tiles are added from the Tiles button
@@ -1066,12 +1113,32 @@ exits green. Use the browser runner for those files and read the file count.
right-click menu offers 2, 4 and 6, remembered per device; the session
picker is gone, and decision 8's "picker on right-click" is superseded. The
owner's answers on the details: the count wins over a remembered grid's
- size (its tiles first, in their cells, holes filled first, then tab order);
+ size (its tiles first, in their cells, holes filled first, then tab order;
+ superseded by decision 11: a click brings the remembered grid back as it
+ was, and only a count picked in the menu re-forms it);
Ctrl/Cmd+click with the grid closed opens the count in total, that session
focused; shrinking keeps the focused tile; only the toggle animates the
close; a remembered count larger than the window stays checked but greyed
and a click opens what fits; the close keeps its dimmed still until the
single view has painted (at most 700 ms); paced connect is in.
+11. **The grid keeps the layout the user arranged, and a fresh one ranks by
+ work.** Decided by the owner ("when I hit the tiles button, it should prefer
+ to load in tiles that are working and then the most recent working, so the
+ oldest dont get opened ... when I moved around and modified it, save it per
+ browser the layout, so when I turn tiles off and on, always keep what the
+ last setting was, if there was no setting before take the working ones, that
+ ones needs input and then the most recent ones in order"). The layout
+ (cells and holes, tile count, divider sizes, focus, a zoom the user chose)
+ is saved per browser on every change and comes back exactly from the toggle,
+ however the grid closed, and from a reload when the grid was open (a grid
+ closed before the reload stays remembered for the toggle; the page shows the
+ single view); it is never filled to the remembered count nor trimmed to the
+ window. A session gone since frees its cell for the
+ ranking; with none left, the grid opens from the ranking (`rankTileSessions`:
+ working, then needing input, then most recent), which also fills every place
+ the grid fills on its own (a count picked in the menu, a freed cell, an open
+ split's fill). Supersedes decision 10's "the count wins over a remembered
+ grid's size"; the count menu itself, its counts and its other answers stay.
## Code anchors
diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md
index 6872ca90..171203b7 100644
--- a/docs/wiki/Contributing.md
+++ b/docs/wiki/Contributing.md
@@ -63,9 +63,8 @@ provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
-or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
-via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
-because that helper caches servers by port.
+or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
+read `server.boundPort`. Never 3000.
## Finding your way around
diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md
index 699c89ee..e45f694f 100644
--- a/docs/wiki/Cron-Jobs.md
+++ b/docs/wiki/Cron-Jobs.md
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
dependencies and open a PR.*
-The ⏰ **Cron** header button is opt-in. Turn it on in
-**App Settings → Header & Panels**.
+The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
+**App Settings → Header & Panels → Scheduling**.
## Creating a job
@@ -96,11 +96,19 @@ The skip policy has the details you would want it to have:
Every fire is recorded per job, with a status:
-| Status | Meaning |
-| --------- | -------------------------------------------------------------------- |
-| `created` | The run started and a session was created. |
-| `skipped` | The concurrency policy blocked it. Not counted as a run. |
-| `failed` | The prompt could not be resolved, or the working directory was gone. |
+| Status | Meaning |
+| ----------------- | ----------------------------------------------------------------- |
+| `created` | The run record was created; the session has not started yet. |
+| `session_started` | The session started; prompt delivery is still pending. |
+| `prompt_sent` | The prompt was sent to the session. |
+| `skipped` | The concurrency policy blocked it. Not counted as a run. |
+| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
+
+History records whether the session started and the prompt was delivered, **not whether
+the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
+If the session is closed during the readiness wait or the server restarts before
+delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
+clients must not poll forever waiting for a terminal status.
The schedule is advanced **before** the session launches, so a slow start cannot cause the
same job to re-trigger.
diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md
index 2fa41b9e..1a5b16a2 100644
--- a/docs/wiki/Driving-Codeman-From-An-Agent.md
+++ b/docs/wiki/Driving-Codeman-From-An-Agent.md
@@ -4,7 +4,8 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
supervising other sessions.**
-Two routes. Start with the skill.
+Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
+`codeman agent` commands. Raw HTTP is there for everything else.
## The agent skill
@@ -59,6 +60,36 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals.
+## The `codeman agent` commands
+
+The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
+`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
+that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
+a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
+ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a
+case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
+
+```bash
+codeman agent ls # sessions; * marks this one
+SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
+codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
+codeman agent read "$SID" # last answer (as the server reads it for that mode)
+codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
+codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
+codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
+codeman agent interrupt "$SID" # a bare ESC, conversation intact
+codeman agent rm "$SID" # any session except this one
+```
+
+- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
+ ambiguous prefix.
+- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
+ starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
+- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
+ prompt is output too, so ask for the marker in halves and wait on the joined form.
+- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
+ worker exited, `4` refused. `--json` prints the response's `data`.
+
## The manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md
index 7cd94f11..b4fb3cae 100644
--- a/docs/wiki/HTTP-API.md
+++ b/docs/wiki/HTTP-API.md
@@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain:
Each route module documents its own endpoints in its file header.
+## Cron jobs
+
+Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
+versioned endpoints are:
+
+| Method | Path | Purpose |
+| --- | --- | --- |
+| GET | `/api/v1/cron/jobs` | List jobs |
+| POST | `/api/v1/cron/jobs` | Create a job |
+| GET | `/api/v1/cron/jobs/:id` | Read a job |
+| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
+| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
+| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
+| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
+| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
+| GET | `/api/v1/cron/runs` | Read all accessible run history |
+
+Responses use the envelope above: job lists/history have arrays in `data`, a
+single-job GET has the job itself, create/update/enable have `{ job }`, delete has
+`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
+`session_started` and `prompt_sent` describe launch/delivery, not task success.
+
+The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
+lists all request and response fields, validation, and ownership restrictions.
+[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
+
## Long-polling instead of polling
Three calls block until something happens, so an agent driving Codeman from a shell can wait
diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md
index ff31d1d3..b3738246 100644
--- a/docs/wiki/Settings-Reference.md
+++ b/docs/wiki/Settings-Reference.md
@@ -90,7 +90,6 @@ every session or only the active tab.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
-| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
@@ -99,11 +98,22 @@ 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). |
| Overview Home Screen | The phone home screen. On by default. |
+### Animations
+
+All per device, all off by default, applied as you pick them.
+
+| Setting | Notes |
+| ---------------------- | ----------------------------------------------------------------------------------------- |
+| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
+| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
+| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
+
### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the
@@ -142,11 +152,15 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
-| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
+| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
### Notifications
-Master toggle, browser notifications, push subscription, audio alerts, the idle
+Master toggle, browser notifications, push subscription, audio alerts, how long a
+corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
+how long a desktop notification stays up before Codeman closes it (**Browser
+notification display time**, default 8; both per device, and your OS may close a
+desktop notification sooner), the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md
index 0795cef0..610df739 100644
--- a/docs/wiki/The-Dashboard.md
+++ b/docs/wiki/The-Dashboard.md
@@ -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
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
-| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
+| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -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 |
@@ -254,7 +258,7 @@ Two extras depending on the device:
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
-| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
+| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
| File Viewer | Header | [Working With Files](Working-With-Files) |
diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md
index c88bb50f..0a788c0b 100644
--- a/docs/wiki/Tile-Grid.md
+++ b/docs/wiki/Tile-Grid.md
@@ -16,23 +16,32 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift
## Opening a grid
-- **Tiles button**: one click shows the tiles straight away, as many as you last chose
- (six until you choose; fewer if the window is too small or you have fewer sessions open).
- You get the grid you last had, its tiles where they were, topped up with your open
- sessions in tab order; if there is none, an open split's two first; otherwise your open
- sessions in tab order, with the session you are on focused. With the grid open, the same
- button closes it.
+- **Tiles button**: one click shows the tiles straight away. If you have used the grid in
+ this browser before, you get it back exactly as you left it: the same sessions in the
+ same places, an empty place where you left one, the same number of tiles, your column
+ widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session
+ closed since frees its place, which is filled the way a new grid is filled (below).
+ Otherwise, or when none of those sessions is left, you get as many tiles as you last
+ chose (six until you choose; fewer if the window is too small or you have fewer sessions
+ open): an open split's two first; otherwise the sessions that are working (the most
+ recently started first), then the ones waiting on you (red and yellow tabs), then the
+ rest, the most recently used first, so the oldest are the ones left out. The session you
+ are on always comes along and is focused. With the grid open, the same button closes it.
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
- count it opens and what a click and a right-click do.
+ count you chose and what a click and a right-click do.
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
- **2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
- and is what the next click opens. With the grid open, picking a count re-forms it: the
- tile you are in always stays, extra tiles leave from the end, new ones join from your tab
- order. A count the window is too small for is greyed out, with the reason.
+ **2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this
+ browser. Picking a count opens the grid with that many tiles (the grid you left, its
+ tiles in their places, new ones in the empty places first), and it is what a click opens
+ when there is no grid to bring back. With the grid open, picking a count re-forms it: the
+ tile you are in always stays, extra tiles leave from the end, new ones join working ones
+ first, then the ones waiting on you, then the most recent. A count the window is too
+ small for is greyed out, with the reason.
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
- the grid closed it opens what the Tiles button would show, with that session among them
- (still the count you chose in total). On macOS use
+ the grid closed it opens what the Tiles button would show with that session added: in the
+ empty place while the grid has fewer tiles than the count you chose, else in place of the
+ last tile (never more than the count). On macOS use
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
@@ -89,7 +98,8 @@ keeps the focus.
A moved tile takes the size of the place it lands in: column widths and row heights stay
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
-the empty slot included, is saved with the grid and comes back on reload.
+the empty slot included, is saved with the grid and comes back when you turn the grid off
+and on, and on a page reload while the grid is open.
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
@@ -122,8 +132,18 @@ session finder) shows that session on its own, the normal single view. The grid
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
same. Narrowing the window below the desktop width also returns to the single view.
-The grid is saved on this device and comes back when you reload the page, with its focus,
-zoom and column widths. A session that was closed in the meantime is simply left out.
+The grid is saved in this browser every time you change it (moving, resizing, adding or
+removing a tile, changing the count, focusing or zooming a tile), and never sent to the
+server. However you leave it (the Tiles button, another tab, Home, a link, closing its last
+tile or session), the Tiles button brings it back as it was. A page reload brings it back
+when the grid was open; after you left it, a reload shows the single view and the Tiles
+button still brings the grid back. A session that was closed or popped out into its own
+window in the meantime frees its place for another one, picked the way a new grid picks
+them; the place stays empty only when no other session is left. Right after a page reload
+the page does not know yet which sessions are waiting for your answer, so that pick goes by
+which sessions are working and which you used last. If the window has become too small for
+all the tiles, the tile you were in fills the grid until the window is wide enough again,
+and the rest of the layout is kept.
Split shows the same logo, name and model above both of its panes.
diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md
index bfa4a6ef..8e7b5afa 100644
--- a/docs/wiki/Working-With-Files.md
+++ b/docs/wiki/Working-With-Files.md
@@ -14,7 +14,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
-| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
+| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
diff --git a/package-lock.json b/package-lock.json
index afe3b16f..8065db1b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
- "version": "1.40.0",
+ "version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
- "version": "1.40.0",
+ "version": "1.41.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -13389,7 +13389,7 @@
}
},
"packages/xterm-zerolag-input": {
- "version": "0.4.0",
+ "version": "0.4.1",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
diff --git a/package.json b/package.json
index 0a0094c8..ee58ec5c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "aicodeman",
- "version": "1.40.0",
- "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
+ "version": "1.41.0",
+ "description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -42,7 +42,7 @@
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
"knip": "npx --yes knip@latest --config config/knip.json",
- "release": "changeset publish"
+ "release": "node scripts/npm-release.mjs"
},
"prettier": {
"singleQuote": true,
@@ -171,7 +171,7 @@
"bugs": {
"url": "https://github.com/Ark0N/Codeman/issues"
},
- "homepage": "https://github.com/Ark0N/Codeman#readme",
+ "homepage": "https://getcodeman.com",
"files": [
"dist",
"scripts/postinstall.js",
diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md
index e88230be..016f936f 100644
--- a/packages/xterm-zerolag-input/CHANGELOG.md
+++ b/packages/xterm-zerolag-input/CHANGELOG.md
@@ -1,5 +1,51 @@
# xterm-zerolag-input
+## 0.4.1
+
+### Patch Changes
+
+- 87f1c9c: ### Thanks
+ - @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
+ - @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
+ - @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
+ - @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
+
+ 
+
+ **Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
+
+ **Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
+
+ **The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
+
+ **`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
+
+ **Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
+
+ **MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
+
+ **Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
+
+ **CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
+
+ **Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
+
+ **Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
+
+ **Fixes.**
+ - **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
+ - **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
+ - **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
+ - **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
+ - **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
+ - **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
+ - **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
+ - **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
+
+ **For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
+
+ **Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
+
## 0.4.0
### Minor Changes
diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md
index bea60476..1129a8a8 100644
--- a/packages/xterm-zerolag-input/README.md
+++ b/packages/xterm-zerolag-input/README.md
@@ -537,7 +537,7 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
### Scroll awareness
-The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
+The overlay hides while the cursor row is scrolled out of the viewport and re-renders, debounced, when it scrolls back into view. A viewport parked a few rows above the bottom keeps painting as long as the cursor row is on screen. A buffer that reports no `cursorY` keeps the bottom-only rule.
---
diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json
index 512f4d37..955a1be8 100644
--- a/packages/xterm-zerolag-input/package.json
+++ b/packages/xterm-zerolag-input/package.json
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
diff --git a/packages/xterm-zerolag-input/src/prompt-finder.ts b/packages/xterm-zerolag-input/src/prompt-finder.ts
index 9d660ede..0ebd2f40 100644
--- a/packages/xterm-zerolag-input/src/prompt-finder.ts
+++ b/packages/xterm-zerolag-input/src/prompt-finder.ts
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
return '';
}
}
+
+/**
+ * Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare
+ * `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above
+ * the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule.
+ */
+export function promptRowInViewport(terminal: XtermTerminal): boolean {
+ try {
+ const buf = terminal.buffer.active;
+ if (buf.viewportY === buf.baseY) return true;
+ if (typeof buf.cursorY !== 'number') return false;
+ const cursorRow = buf.baseY + buf.cursorY;
+ return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
index 12c0dfa8..3993de8b 100644
--- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
+++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
@@ -8,7 +8,7 @@ import type {
FontStyle,
} from './types.js';
import { getCellDimensions } from './cell-dimensions.js';
-import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
+import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js';
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
@@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon {
// Cache font properties
this._cacheFont();
- // Scroll detection: hide overlay when scrolled away from bottom
+ // Scroll detection: hide the overlay while the cursor row is scrolled out of view
this._scrollHandler = () => {
try {
- const buf = this._terminal!.buffer.active;
- if (buf.viewportY !== buf.baseY) {
+ if (!promptRowInViewport(this._terminal!)) {
this._overlay!.style.display = 'none';
if (this._scrollTimer) {
clearTimeout(this._scrollTimer);
@@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon {
try {
const buf = this._terminal.buffer.active;
- // Hide overlay when scrolled up — prompt is at bottom, not in viewport
- if (buf.viewportY !== buf.baseY) {
+ // Hide the overlay while the cursor row is scrolled out of view
+ if (!promptRowInViewport(this._terminal)) {
this._overlay.style.display = 'none';
return;
}
diff --git a/packages/xterm-zerolag-input/test/prompt-finder.test.ts b/packages/xterm-zerolag-input/test/prompt-finder.test.ts
index 69942a31..4c09ff04 100644
--- a/packages/xterm-zerolag-input/test/prompt-finder.test.ts
+++ b/packages/xterm-zerolag-input/test/prompt-finder.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createMockTerminal } from './helpers.js';
-import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
+import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import type { XtermTerminal, PromptFinder } from '../src/types.js';
function term(lines: string[]) {
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
cleanup();
});
});
+
+describe('promptRowInViewport', () => {
+ const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) =>
+ ({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never;
+
+ it('is true at the bottom regardless of the cursor', () => {
+ expect(promptRowInViewport(term(10, 10, undefined))).toBe(true);
+ });
+
+ it('is true for a viewport parked above the bottom while the cursor row is on screen', () => {
+ // scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row
+ expect(promptRowInViewport(term(0, 16, 5))).toBe(true);
+ // cursor exactly on the last visible row
+ expect(promptRowInViewport(term(0, 23, 0))).toBe(true);
+ });
+
+ it('is false once the cursor row is scrolled out of the viewport', () => {
+ expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row
+ expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history
+ });
+
+ it('keeps the bottom-only rule when the buffer has no cursorY', () => {
+ expect(promptRowInViewport(term(0, 1, undefined))).toBe(false);
+ });
+});
diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
index 74063761..e4653868 100644
--- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
+++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
@@ -725,3 +725,34 @@ describe('ZerolagInputAddon', () => {
});
});
});
+
+describe('viewport scrolled away from the bottom', () => {
+ function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) {
+ const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows });
+ const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => {
+ addon.dispose();
+ mock.cleanup();
+ });
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ return { addon, mock, overlay };
+ }
+
+ it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => {
+ // The host parks the viewport to keep trailing blank rows out of view; the
+ // prompt and cursor are still visible, so the user's text must be too.
+ const { addon, overlay } = parked(0, 1, 0);
+ addon.appendText('abc');
+ expect(addon.pendingText).toBe('abc');
+ expect(overlay.style.display).not.toBe('none');
+ expect(overlay.textContent).toContain('abc');
+ });
+
+ it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => {
+ const { addon, overlay } = parked(0, 30, 0);
+ addon.appendText('abc');
+ expect(addon.pendingText).toBe('abc');
+ expect(overlay.style.display).toBe('none');
+ });
+});
diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json
index df5a0781..3f464ebe 100644
--- a/plugins/codeman/.claude-plugin/plugin.json
+++ b/plugins/codeman/.claude-plugin/plugin.json
@@ -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.40.0",
+ "version": "1.41.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md
index d328b967..c965c998 100644
--- a/plugins/codeman/skills/codeman/SKILL.md
+++ b/plugins/codeman/skills/codeman/SKILL.md
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
+Workers in **every other mode** never receive this preamble, but they have the same
+environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
+(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
+same endpoints, with the guards below (no control bytes, no self-delete, no guessed
+URL) enforced in code.
+
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
diff --git a/scripts/npm-release.mjs b/scripts/npm-release.mjs
new file mode 100644
index 00000000..a27282c0
--- /dev/null
+++ b/scripts/npm-release.mjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+/**
+ * @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
+ *
+ * npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
+ * @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
+ * keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
+ * Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
+ * `files` cannot help: npm-packlist always includes every root `README.*`.
+ *
+ * Renaming the file would break every link to it, so for the length of the publish only it
+ * moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
+ * whatever the publish did. The move happens HERE, inside the publish command, never as a
+ * step before `changesets/action` in release.yml: that action also runs the version path and
+ * commits the working tree into its version PR, which would commit the deletion.
+ *
+ * A previous run killed between the move and the restore leaves the aside copy behind; the
+ * next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
+ *
+ * node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
+ */
+import { existsSync, renameSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const HIDDEN_README = 'README.zh-CN.md';
+export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
+
+/**
+ * Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
+ * when `publish` fails or throws. Returns the exit code `publish` returned.
+ *
+ * @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
+ * @returns {number}
+ */
+export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
+ const original = join(root, HIDDEN_README);
+ const aside = join(root, ASIDE_NAME);
+ if (existsSync(aside) && !existsSync(original)) {
+ renameSync(aside, original);
+ log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
+ }
+ const moved = existsSync(original);
+ if (moved) {
+ renameSync(original, aside);
+ log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
+ }
+ try {
+ return publish();
+ } finally {
+ if (moved) {
+ renameSync(aside, original);
+ log(`npm-release: ${HIDDEN_README} restored`);
+ }
+ }
+}
+
+function runChangesetPublish() {
+ const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
+ if (result.error) {
+ console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
+ return 1;
+ }
+ return result.status ?? 1;
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+ process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
+}
diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md
index d328b967..c965c998 100644
--- a/skills/codeman/SKILL.md
+++ b/skills/codeman/SKILL.md
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
+Workers in **every other mode** never receive this preamble, but they have the same
+environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
+(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
+same endpoints, with the guards below (no control bytes, no self-delete, no guessed
+URL) enforced in code.
+
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
diff --git a/src/cli-agent.ts b/src/cli-agent.ts
new file mode 100644
index 00000000..4a295af5
--- /dev/null
+++ b/src/cli-agent.ts
@@ -0,0 +1,980 @@
+/**
+ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running
+ * inside a Codeman session, in every CLI mode.
+ *
+ * A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
+ * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
+ * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
+ * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
+ * the packaged agent skill's (`skills/codeman`), ported from shell prose into code
+ * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
+ * preamble — has the same verbs from one line in its AGENTS.md.
+ *
+ * Invariants (each asserted in `test/cli-agent.test.ts`):
+ * 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
+ * never guesses a URL — a server you are not part of is not yours to drive.
+ * 2. `send` transmits printable text plus `\r` only. ESC exists solely as
+ * `interrupt`, which never appends `\r`. A stray control byte is a dead session
+ * in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
+ * 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
+ * direction refuses. Ids appear in full and 8-char form, so equality alone
+ * misses a real combination — and the miss deletes the caller.
+ * 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
+ * verb. `9` would resolve to whichever session is alone with that first
+ * character, the user's own interactive tab included.
+ *
+ * Commands live here as functions returning an exit code, not calling
+ * `process.exit`, so the whole surface is unit-testable against a fake server.
+ *
+ * @module cli-agent
+ */
+import http from 'node:http';
+import https from 'node:https';
+import type { Command } from 'commander';
+import {
+ basicAuthHeader,
+ credentialsFrom,
+ readCodemanEnvFile,
+ type CodemanCredentials,
+} from './codeman-credentials.js';
+import { getCli } from './config/cli-registry/registry.js';
+import { GLYPH, palette, table } from './cli-style.js';
+import { getErrorMessage } from './types.js';
+import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Context and guard
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface AgentContext {
+ /** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
+ apiUrl: string;
+ /** This session's id, from `CODEMAN_SESSION_ID`. */
+ selfId: string;
+ /** Basic-auth credentials, when the server has a password. */
+ auth?: CodemanCredentials;
+}
+
+/** Thrown when the process is not inside a Codeman-managed session. */
+export class AgentGuardError extends Error {}
+
+/** Exit codes shared by every verb; a shell agent can branch on them. */
+export const EXIT = {
+ ok: 0,
+ error: 1,
+ timeout: 2,
+ dead: 3,
+ refused: 4,
+} as const;
+
+/**
+ * Resolve the context from the environment, or throw `AgentGuardError`.
+ *
+ * Credentials in the order every client of the API uses (`credentialsFrom`, shared
+ * with `codeman attach` and the TUI): each field from the environment (a session
+ * inherits the server's), then the data dir's `.env`. No password means the server
+ * is open (single-user) — or it is not, and the 401 says so.
+ */
+export function resolveAgentContext(
+ env: NodeJS.ProcessEnv = process.env,
+ envFile: () => Record = readCodemanEnvFile
+): AgentContext {
+ if (env.CODEMAN_MUX !== '1') {
+ throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
+ }
+ const apiUrl = env.CODEMAN_API_URL?.trim();
+ if (!apiUrl) {
+ throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
+ }
+ const selfId = env.CODEMAN_SESSION_ID?.trim();
+ if (!selfId) {
+ throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
+ }
+ const credentials = credentialsFrom(env, envFile());
+ return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Pure helpers (the invariants)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Is `id` this session? Prefix in BOTH directions, because ids appear in full and
+ * in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
+ * shorter than 8 characters cannot prove anything and is treated as "maybe me".
+ */
+export function isSelfSession(selfId: string, id: string): boolean {
+ if (!id || selfId.length < 8) return true;
+ return id.startsWith(selfId) || selfId.startsWith(id);
+}
+
+/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
+export function deleteRefusal(selfId: string, id: string): string | undefined {
+ if (!id) return 'refusing: empty session id';
+ if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
+ if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
+ return undefined;
+}
+
+/**
+ * The prompt `send` was given, which must be ONE argument. Joining several with spaces
+ * would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
+ * back into a single line, so the multi-line refusal below would never see it.
+ */
+export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
+ if (words.length === 1) return { text: words[0] };
+ return {
+ error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send "…"\`; a prompt that starts with "-" goes after --: \`send -- "- fix the bug"\`)`,
+ };
+}
+
+/**
+ * Why `send` refuses this text, or `undefined` when it is printable. The composer
+ * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
+ * DEL) reaches the pane as a keypress. None of that is a prompt.
+ */
+export function inputRefusal(text: string): string | undefined {
+ if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send ""` is never a prompt)';
+ // The composer is one line: the server strips newlines, which silently joins the
+ // lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
+ if (/[\n\r\t]/.test(text)) {
+ return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
+ }
+ // C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
+ // eslint-disable-next-line no-control-regex
+ const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
+ if (control) {
+ const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
+ return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
+ }
+ return undefined;
+}
+
+/**
+ * Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
+ * caller asked to type without submitting. Never anything else.
+ */
+export function buildSendBody(
+ text: string,
+ options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
+): Record {
+ const body: Record = {
+ input: options.enter ? `${text}\r` : text,
+ useMux: true,
+ clientId: options.clientId,
+ seq: options.seq,
+ };
+ if (options.wait !== undefined) body.wait = options.wait;
+ if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout;
+ return body;
+}
+
+/** Body for the interrupt path: a bare ESC, and nothing appended — ever. */
+export function buildInterruptBody(clientId: string, seq: number): Record {
+ return { input: '\u001b', useMux: true, clientId, seq };
+}
+
+/** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */
+export function defaultClientId(selfId: string, suffix = ''): string {
+ return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
+}
+
+/**
+ * Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
+ * titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
+ */
+export function stripAnsi(text: string): string {
+ // eslint-disable-next-line no-control-regex
+ return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
+}
+
+/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
+export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
+ if (raw === undefined) return fallback;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n <= 0) {
+ throw new Error(`${flag} must be a positive integer, got "${raw}"`);
+ }
+ return n;
+}
+
+/**
+ * Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
+ * timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
+ * during `--until stop` comes back as `ended:true, signal:null` (the registry only
+ * satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
+ * a dead worker as `ended:true, matched:false` — both are "dead", never "done".
+ */
+export function waitExitCode(wait: WaitResult | undefined): number {
+ if (!wait) return EXIT.error;
+ if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
+ if (wait.timedOut) return EXIT.timeout;
+ if (wait.matched === false) return EXIT.timeout;
+ return EXIT.ok;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// HTTP
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface ApiEnvelope {
+ success: boolean;
+ data?: T;
+ error?: string;
+ errorCode?: string;
+}
+
+export interface ApiResponse {
+ status: number;
+ /** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
+ json?: ApiEnvelope;
+ text: string;
+}
+
+export interface WaitResult {
+ /** The signal that fired, or null when the wait ended without one. */
+ signal?: string | null;
+ timedOut?: boolean;
+ /** The session went away (deleted / torn down / the write failed) before the wait resolved. */
+ ended?: boolean;
+ timeoutMs?: number;
+ until?: string[];
+ matched?: boolean;
+ match?: string;
+ snippet?: string;
+ immediate?: boolean;
+}
+
+export interface RequestOptions {
+ method: 'GET' | 'POST' | 'DELETE';
+ path: string;
+ query?: Record;
+ body?: Record;
+ headers?: Record;
+ /** Socket timeout; long-polls pass their own timeout plus headroom. */
+ timeoutMs?: number;
+}
+
+export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise;
+
+/** Every request carries these; they are ignored on endpoints that do not read them. */
+export function baseHeaders(ctx: AgentContext): Record {
+ const headers: Record = {
+ Accept: 'application/json',
+ // Tags sessions this caller spawns as its children (lineage in the web UI).
+ // Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
+ // directory as deletable agent scratch, so it rides only the spawn request that
+ // may create one (see agentSpawn), never anything else.
+ 'X-Codeman-Parent-Session': ctx.selfId,
+ };
+ const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
+ if (authorization) headers.Authorization = authorization;
+ return headers;
+}
+
+/** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */
+export const httpRequest: ApiRequest = (ctx, options) => {
+ const url = new URL(options.path, ctx.apiUrl);
+ for (const [key, value] of Object.entries(options.query ?? {})) {
+ if (value !== undefined) url.searchParams.set(key, String(value));
+ }
+ const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body);
+ const headers: Record = { ...baseHeaders(ctx), ...(options.headers ?? {}) };
+ if (bodyText !== undefined) {
+ headers['Content-Type'] = 'application/json';
+ headers['Content-Length'] = Buffer.byteLength(bodyText);
+ }
+ const transport = url.protocol === 'https:' ? https : http;
+ return new Promise((resolve, reject) => {
+ const req = transport.request(
+ {
+ protocol: url.protocol,
+ hostname: url.hostname,
+ port: url.port,
+ method: options.method,
+ path: `${url.pathname}${url.search}`,
+ rejectUnauthorized: false,
+ headers,
+ timeout: options.timeoutMs ?? 30_000,
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf-8');
+ let json: ApiEnvelope | undefined;
+ try {
+ json = JSON.parse(text) as ApiEnvelope;
+ } catch {
+ json = undefined;
+ }
+ resolve({ status: res.statusCode ?? 0, json, text });
+ });
+ }
+ );
+ req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`)));
+ req.on('error', reject);
+ if (bodyText !== undefined) req.write(bodyText);
+ req.end();
+ });
+};
+
+/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
+export function describeFailure(res: ApiResponse): string {
+ if (res.json && !res.json.success) {
+ return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`;
+ }
+ const text = res.text.trim().split('\n')[0] ?? '';
+ if (res.status === 401)
+ return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`;
+ return `HTTP ${res.status}${text ? ` ${text}` : ''}`;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Commands
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface AgentIo {
+ out: (line: string) => void;
+ err: (line: string) => void;
+}
+
+export interface AgentDeps {
+ ctx: AgentContext;
+ request: ApiRequest;
+ io: AgentIo;
+ json: boolean;
+ /** Clock for `seq`; injectable so tests are deterministic. */
+ now?: () => number;
+}
+
+/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
+function emitJson(deps: AgentDeps, data: unknown): void {
+ deps.io.out(JSON.stringify(data, null, 2));
+}
+
+function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number {
+ if (deps.json) {
+ deps.io.out(JSON.stringify({ success: false, error: message }));
+ } else {
+ deps.io.err(palette.err(`${GLYPH.fail} ${message}`));
+ }
+ return code;
+}
+
+interface SessionRow {
+ id: string;
+ name?: string;
+ mode?: string;
+ status?: string;
+ workingDir?: string;
+ pid?: number | null;
+ parentSessionId?: string | null;
+}
+
+/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
+const FULL_ID_LENGTH = 36;
+
+/**
+ * Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
+ * the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
+ */
+export const MIN_ID_PREFIX_LENGTH = 8;
+
+/**
+ * Turn the id a human typed into the one the routes accept. `ls` prints 8-char
+ * prefixes and the routes answer 404 to those (measured live), so anything shorter
+ * than a full id resolves through the session list; an ambiguous prefix refuses
+ * rather than picking one. Below 8 characters it refuses before the list: "unique"
+ * means nothing for `9` — it names whatever session happens to be alone with that
+ * first character, and `rm`/`send` would act on it.
+ */
+export async function resolveSessionId(
+ deps: AgentDeps,
+ id: string
+): Promise<{ id: string } | { error: string; code: number }> {
+ if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
+ if (id.length < MIN_ID_PREFIX_LENGTH) {
+ return {
+ error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
+ code: EXIT.refused,
+ };
+ }
+ if (id.length >= FULL_ID_LENGTH) return { id };
+ const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
+ if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
+ const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
+ if (matches.length === 1) return { id: matches[0].id };
+ if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
+ return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
+}
+
+/** `agent ls` — every session the caller can see, self marked. */
+export async function agentLs(deps: AgentDeps): Promise {
+ const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
+ if (deps.json) {
+ emitJson(
+ deps,
+ sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
+ );
+ return EXIT.ok;
+ }
+ if (sessions.length === 0) {
+ deps.io.out(palette.muted('(no sessions)'));
+ return EXIT.ok;
+ }
+ const rows = sessions.map((s) => [
+ isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
+ s.id.slice(0, 8),
+ s.mode ?? '?',
+ s.status ?? '?',
+ s.name || s.workingDir || '',
+ ]);
+ deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
+ deps.io.out(
+ palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
+ );
+ return EXIT.ok;
+}
+
+export interface SpawnOptions {
+ caseName: string;
+ mode: string;
+ name?: string;
+ /** Wait for the composer before returning, where the registry gives the mode a ready mark. */
+ ready: boolean;
+ timeoutMs: number;
+}
+
+/**
+ * Agent-scratch label for a case directory a spawn CREATES (the server applies it only
+ * when quick-start makes the directory). The Add Case UI offers a recursive delete for
+ * such directories, so this header must never ride any other request: mislabelling a
+ * real repo there is the one failure in this area that costs actual work.
+ */
+export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
+
+/**
+ * What the mode's TUI draws once its composer can take a prompt, from the CLI registry
+ * (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
+ */
+export function composerReadyMark(mode: string): string | undefined {
+ return getCli(mode)?.capabilities.composerReadyMark;
+}
+/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
+export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise {
+ const body: Record = {
+ caseName: options.caseName,
+ mode: options.mode,
+ parentSessionId: deps.ctx.selfId,
+ };
+ if (options.name) body.sessionName = options.name;
+ const res = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: '/api/v1/quick-start',
+ body,
+ headers: { ...AGENT_ORIGIN_HEADER },
+ });
+ const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
+ if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
+ const sid = data.sessionId;
+
+ let ready: boolean | undefined;
+ let readinessError: string | undefined;
+ let dead = false;
+ const mark = composerReadyMark(options.mode);
+ if (options.ready && mark) {
+ const wait = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
+ query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
+ timeoutMs: options.timeoutMs + 10_000,
+ });
+ // A failed readiness call (waiter cap, 400, network) is its own error, not "the
+ // composer never showed up": report the real reason instead of the trust-dialog hint.
+ if (!wait.json?.success) readinessError = describeFailure(wait);
+ else {
+ const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
+ // A worker that died while we waited is exit 3 like every other wait, not a
+ // "composer not seen" timeout that sends the caller looking for a dialog.
+ dead = waitExitCode(result) === EXIT.dead;
+ ready = !dead && Boolean(result?.matched);
+ }
+ }
+
+ if (deps.json) {
+ emitJson(deps, { ...data, ready, readinessError });
+ } else {
+ // Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
+ const say = (line: string) => deps.io.err(line);
+ say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
+ if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
+ if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
+ if (ready === false && !dead) {
+ say(
+ palette.warn(
+ `${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
+ )
+ );
+ }
+ if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
+ if (ready === undefined && !readinessError && options.ready) {
+ say(
+ palette.muted(
+ ` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
+ )
+ );
+ }
+ deps.io.out(sid);
+ }
+ if (readinessError) return EXIT.error;
+ if (dead) return EXIT.dead;
+ return ready === false ? EXIT.timeout : EXIT.ok;
+}
+
+export interface SendOptions {
+ id: string;
+ text: string;
+ enter: boolean;
+ /** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
+ wait?: string | true;
+ timeoutMs?: number;
+ clientId?: string;
+ seq?: number;
+}
+
+/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
+export async function agentSend(deps: AgentDeps, options: SendOptions): Promise {
+ if (isSelfSession(deps.ctx.selfId, options.id)) {
+ return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused);
+ }
+ const refusal = inputRefusal(options.text);
+ if (refusal) return fail(deps, refusal, EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const body = buildSendBody(options.text, {
+ enter: options.enter,
+ clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
+ seq: options.seq ?? (deps.now ?? Date.now)(),
+ wait: options.wait,
+ waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
+ });
+ const res = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
+ body,
+ timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const data = res.json.data as
+ | { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
+ | undefined;
+ if (deps.json) emitJson(deps, data ?? {});
+ // Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
+ // holds the chunk and types it once the pane is back (`buffered`), or the chunk was
+ // over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
+ // so a retry needs a new one (the default, the clock, gives it that).
+ if (data?.dropped) {
+ if (!deps.json) {
+ deps.io.err(
+ palette.err(
+ `${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
+ )
+ );
+ }
+ return EXIT.error;
+ }
+ // `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
+ // the PTY exited or send-keys hit a dead pane. The field exists so a client does not
+ // say "wait longer" when the truth is "restart the worker" — so it is a failure here.
+ if (data?.delivered === false && !data.duplicate) {
+ if (!deps.json) {
+ deps.io.err(
+ palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
+ );
+ }
+ return EXIT.dead;
+ }
+ if (!deps.json) {
+ const noEnter = options.enter ? '' : ' (no Enter)';
+ if (data?.duplicate) {
+ deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
+ } else if (data?.buffered) {
+ deps.io.out(
+ palette.ok(
+ `${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
+ )
+ );
+ } else if (data?.delivered === true) {
+ deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
+ } else {
+ // Fire-and-forget answers before the write, so there is no delivery report here.
+ deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
+ }
+ if (data?.wait) deps.io.out(describeWait(data.wait));
+ }
+ if (options.wait === undefined) return EXIT.ok;
+ return waitExitCode(data?.wait);
+}
+
+function describeWait(wait: WaitResult): string {
+ if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
+ if (wait.ended) {
+ return palette.err(
+ `${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
+ );
+ }
+ // A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
+ // so the line stays neutral instead of looking like an error to whoever reads the log.
+ if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
+ if (wait.matched !== undefined) {
+ return wait.matched
+ ? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
+ : palette.muted('not matched (exit 2)');
+ }
+ return palette.ok(
+ `${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
+ );
+}
+
+export interface WaitOptions {
+ id: string;
+ until?: string;
+ match?: string;
+ from?: 'buffer' | 'now';
+ fresh?: boolean;
+ nocase?: boolean;
+ timeoutMs: number;
+}
+
+/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
+export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise {
+ if (options.until && options.match)
+ return fail(deps, 'use either --until or --match , not both', EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const sid = encodeURIComponent(target.id);
+ const res = options.match
+ ? await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/wait-output`,
+ query: {
+ match: options.match,
+ from: options.from ?? 'buffer',
+ nocase: options.nocase ? 1 : undefined,
+ timeout: options.timeoutMs,
+ },
+ timeoutMs: options.timeoutMs + 10_000,
+ })
+ : await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/wait`,
+ query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
+ timeoutMs: options.timeoutMs + 10_000,
+ });
+ // A 400 here is the server saying "this mode has no such signal" (until=stop on an
+ // external CLI). Passed through, never papered over: the marker path is the answer.
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
+ if (deps.json) {
+ emitJson(deps, data ?? {});
+ } else if (data?.wait) {
+ deps.io.out(describeWait(data.wait));
+ if (data.limitPaused)
+ deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
+ }
+ return waitExitCode(data?.wait);
+}
+
+export interface ReadOptions {
+ id: string;
+ /** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
+ tail?: number;
+ /** Whole conversation (`context=full`) instead of the last assistant message. */
+ full?: boolean;
+}
+
+/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
+export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise {
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const sid = encodeURIComponent(target.id);
+ if (options.tail !== undefined) {
+ const res = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/terminal`,
+ query: { tail: options.tail },
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
+ if (deps.json) emitJson(deps, res.json.data);
+ else deps.io.out(stripAnsi(buffer));
+ return EXIT.ok;
+ }
+ const res = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/last-response`,
+ query: { context: options.full ? 'full' : undefined },
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const data = res.json.data as
+ | { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
+ | undefined;
+ if (deps.json) {
+ emitJson(deps, data ?? {});
+ return EXIT.ok;
+ }
+ if (options.full && data?.messages) {
+ for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
+ return EXIT.ok;
+ }
+ const text = data?.text ?? '';
+ if (!text) {
+ deps.io.err(
+ palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
+ );
+ return EXIT.ok;
+ }
+ deps.io.out(text);
+ return EXIT.ok;
+}
+
+/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
+export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise {
+ if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
+ const res = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
+ body,
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ if (deps.json) emitJson(deps, res.json.data ?? {});
+ else
+ deps.io.out(
+ palette.ok(
+ `${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
+ )
+ );
+ return EXIT.ok;
+}
+
+/** `agent rm` — delete a session that is provably not this one. */
+export async function agentRm(deps: AgentDeps, options: { id: string }): Promise {
+ const refusal = deleteRefusal(deps.ctx.selfId, options.id);
+ if (refusal) return fail(deps, refusal, EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ // The guard again on the RESOLVED id: a prefix that is not me can still resolve
+ // to me only if the list is lying, but a delete is the one call worth the paranoia.
+ const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
+ if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
+ const res = await deps.request(deps.ctx, {
+ method: 'DELETE',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ if (deps.json) emitJson(deps, res.json.data ?? {});
+ else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
+ return EXIT.ok;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Commander wiring
+// ─────────────────────────────────────────────────────────────────────────────
+
+const DEFAULT_WAIT_MS = 60_000;
+
+/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
+function liveDeps(json: boolean): AgentDeps | undefined {
+ try {
+ return {
+ ctx: resolveAgentContext(),
+ request: httpRequest,
+ json,
+ io: { out: (line) => console.log(line), err: (line) => console.error(line) },
+ };
+ } catch (err) {
+ if (err instanceof AgentGuardError) {
+ console.error(palette.err(`${GLYPH.fail} ${err.message}`));
+ return undefined;
+ }
+ throw err;
+ }
+}
+
+/** Run a verb with the live transport and turn its exit code into the process exit. */
+async function run(json: boolean, verb: (deps: AgentDeps) => Promise): Promise {
+ const deps = liveDeps(json);
+ if (!deps) {
+ process.exitCode = EXIT.refused;
+ return;
+ }
+ try {
+ process.exitCode = await verb(deps);
+ } catch (err) {
+ console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
+ process.exitCode = EXIT.error;
+ }
+}
+
+/** Register `codeman agent …` on the program. */
+export function registerAgentCommands(program: Command): Command {
+ const agent = program
+ .command('agent')
+ .description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
+
+ agent
+ .command('ls')
+ .alias('list')
+ .description('List sessions; * marks this one')
+ .option('--json', 'Machine-readable output')
+ .action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
+
+ agent
+ .command('spawn ')
+ .description(
+ 'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
+ )
+ .option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude')
+ .option('-n, --name ', 'Session name shown in the UI')
+ .option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
+ .option('-t, --timeout ', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
+ .option('--json', 'Machine-readable output')
+ .action(
+ (caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
+ run(Boolean(options.json), (deps) =>
+ agentSpawn(deps, {
+ caseName,
+ mode: options.mode,
+ name: options.name,
+ ready: options.ready,
+ timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
+ })
+ )
+ );
+
+ agent
+ .command('send ')
+ .description(
+ 'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send -- "- fix the bug")'
+ )
+ .option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
+ .option('-u, --until ', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
+ .option('-t, --timeout ', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
+ .option('--no-enter', 'Type the text without submitting it')
+ .option('--client-id ', 'Exactly-once tag (default: one per calling session)')
+ .option(
+ '--seq ',
+ 'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
+ )
+ .option('--json', 'Machine-readable output')
+ .action(
+ (
+ id: string,
+ words: string[],
+ options: {
+ wait?: boolean;
+ until?: string;
+ timeout?: string;
+ enter: boolean;
+ clientId?: string;
+ seq?: string;
+ json?: boolean;
+ }
+ ) =>
+ run(Boolean(options.json), (deps) => {
+ const prompt = sendPromptFromArgs(words);
+ if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
+ return agentSend(deps, {
+ id,
+ text: prompt.text,
+ enter: options.enter,
+ wait: options.until ?? (options.wait ? true : undefined),
+ timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
+ clientId: options.clientId,
+ seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
+ });
+ })
+ );
+
+ agent
+ .command('wait ')
+ .description(
+ 'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
+ )
+ .option(
+ '-u, --until ',
+ 'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
+ )
+ .option(
+ '-m, --match ',
+ 'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
+ )
+ .option('--from ', 'buffer (scan existing output first, the default) or now', 'buffer')
+ .option('--nocase', 'Case-insensitive --match')
+ .option('--fresh', 'Require an actual transition (--until only)')
+ .option('-t, --timeout ', 'Budget in ms', String(DEFAULT_WAIT_MS))
+ .option('--json', 'Machine-readable output')
+ .action(
+ (
+ id: string,
+ options: {
+ until?: string;
+ match?: string;
+ from: string;
+ nocase?: boolean;
+ fresh?: boolean;
+ timeout?: string;
+ json?: boolean;
+ }
+ ) =>
+ run(Boolean(options.json), (deps) =>
+ agentWait(deps, {
+ id,
+ until: options.until,
+ match: options.match,
+ from: options.from === 'now' ? 'now' : 'buffer',
+ nocase: options.nocase,
+ fresh: options.fresh,
+ timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
+ })
+ )
+ );
+
+ agent
+ .command('read ')
+ .description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
+ .option('--tail ', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)')
+ .option('--full', 'The whole conversation instead of the last assistant message')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) =>
+ run(Boolean(options.json), (deps) =>
+ agentRead(deps, {
+ id,
+ tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'),
+ full: options.full,
+ })
+ )
+ );
+
+ agent
+ .command('interrupt ')
+ .description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { json?: boolean }) =>
+ run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
+ );
+
+ agent
+ .command('rm ')
+ .description('Delete any session except this one (refuses your own id)')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
+
+ return agent;
+}
diff --git a/src/cli.ts b/src/cli.ts
index 1083f777..03692780 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -15,9 +15,11 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
+import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
+import { registerAgentCommands } from './cli-agent.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -42,32 +44,8 @@ function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
-function readCodemanEnv(): Record {
- const envPath = dataPath('.env');
- try {
- const text = readFileSync(envPath, 'utf-8');
- const result: Record = {};
- for (const rawLine of text.split(/\r?\n/)) {
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) continue;
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
- if (!match) continue;
- let value = match[2].trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- result[match[1]] = value;
- }
- return result;
- } catch {
- return {};
- }
-}
-
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise {
- const envFile = readCodemanEnv();
- const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
- const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
+ const { username, password } = readCodemanCredentials();
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
@@ -252,6 +230,10 @@ skillCmd
}
});
+// ============ Agent Commands (session-to-session, any CLI mode) ============
+
+registerAgentCommands(program);
+
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -641,9 +623,7 @@ function probeWebServerAt(base: string): Promise {
} catch {
return Promise.resolve(null);
}
- const envFile = readCodemanEnv();
- const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
- const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
+ const { username, password } = readCodemanCredentials();
const transport = url.protocol === 'https:' ? https : http;
const headers: Record = { Accept: 'application/json' };
if (password) {
diff --git a/src/codeman-credentials.ts b/src/codeman-credentials.ts
new file mode 100644
index 00000000..fd18dfc4
--- /dev/null
+++ b/src/codeman-credentials.ts
@@ -0,0 +1,75 @@
+/**
+ * @fileoverview Credentials for a client of this Codeman instance's own API.
+ *
+ * Env first, the data dir's `.env` as the fallback — the hand-authored file
+ * `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
+ * three clients cannot drift on quoting, comments or the default username.
+ *
+ * @module codeman-credentials
+ */
+import { readFileSync } from 'node:fs';
+import { dataPath } from './config/instance.js';
+
+export interface CodemanCredentials {
+ username: string;
+ /** Absent when no password is configured (or only the server's environment has it). */
+ password?: string;
+}
+
+/**
+ * Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
+ * prefix tolerated (the file is hand-authored, often sourced by a shell too), one
+ * layer of matching quotes stripped, anything that is not an assignment ignored.
+ */
+export function parseEnvFile(text: string): Record {
+ const result: Record = {};
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+ if (!match) continue;
+ let value = match[2].trim();
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ }
+ result[match[1]] = value;
+ }
+ return result;
+}
+
+/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
+export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record {
+ try {
+ return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * The lookup order every client uses, per field: the environment, then the `.env`
+ * file, then (username only) `admin`. Pure, so a caller with its own environment
+ * object (`codeman agent`'s guard takes one for testability) gets the same answer.
+ */
+export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record): CodemanCredentials {
+ const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
+ const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
+ return password ? { username, password } : { username };
+}
+
+/**
+ * Credentials for the API. No password means no auth is configured, or the user has
+ * it only in the server's environment, in which case the API answers 401.
+ */
+export function readCodemanCredentials(
+ envFilePath: string = dataPath('.env'),
+ env: NodeJS.ProcessEnv = process.env
+): CodemanCredentials {
+ return credentialsFrom(env, readCodemanEnvFile(envFilePath));
+}
+
+/** `Authorization` header value, or undefined when there is no password to send. */
+export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
+ if (!credentials.password) return undefined;
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts
index 10a9b932..78322ca7 100644
--- a/src/config/cli-registry/schema.ts
+++ b/src/config/cli-registry/schema.ts
@@ -324,6 +324,8 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
+ // Literal text matched by a `wait-output` long-poll, never compiled as a regex.
+ composerReadyMark: z.string().min(1).max(64).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -453,6 +455,7 @@ const capabilitiesSchema = z
'codex-toml',
'opencode-json',
'antigravity-json',
+ 'copilot-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts
index cb31df64..cac4e40a 100644
--- a/src/config/cli-registry/stock.ts
+++ b/src/config/cli-registry/stock.ts
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
+ // The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
+ // trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
+ composerReadyMark: 'shift+tab',
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
@@ -1356,6 +1359,8 @@ const DEEPSEEK: CliEntry = {
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
// the session can disarm the bridge, and docker/remote cannot reach it at all.
hooks: 'supervised',
+ // dsh's composer glyph, drawn once the harness TUI can take a prompt.
+ composerReadyMark: '❯',
transcript: 'deepseek-zstd',
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts
index d457362d..b784825f 100644
--- a/src/config/cli-registry/types.ts
+++ b/src/config/cli-registry/types.ts
@@ -105,7 +105,13 @@ export type ModelConfigResolverName = 'deepseek-route';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
-export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
+export type McpConfigFormat =
+ | 'claude-json'
+ | 'gemini-json'
+ | 'codex-toml'
+ | 'opencode-json'
+ | 'antigravity-json'
+ | 'copilot-json';
export interface CliLaunch {
params: Record;
@@ -416,6 +422,17 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
+ /**
+ * Literal text the TUI draws once its composer can take a prompt — what `codeman agent
+ * spawn` waits for (a `wait-output` match) before it calls a worker ready.
+ *
+ * Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
+ * of its workspace-trust dialog, which is exactly the screen a readiness wait must not
+ * mistake for a composer, so claude declares its composer's own hint text instead.
+ * Absent means no readiness wait: a spawn returns as soon as the session exists, and
+ * the caller synchronizes on `wait-output` markers.
+ */
+ composerReadyMark?: string;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
diff --git a/src/git-clone.ts b/src/git-clone.ts
index b9cd3708..ef3380e8 100644
--- a/src/git-clone.ts
+++ b/src/git-clone.ts
@@ -495,6 +495,10 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
SSH_ASKPASS_REQUIRE: 'never',
DISPLAY: '',
GCM_INTERACTIVE: 'never',
+ // classifyGitFailure() matches git's ENGLISH stderr; a German or French locale would
+ // turn a missing ref into a generic FAILED (422 instead of 400).
+ LC_ALL: 'C',
+ LANG: 'C',
GIT_SSH_COMMAND:
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
};
diff --git a/src/image-watcher.ts b/src/image-watcher.ts
index e0f8c7ae..8f8dc560 100644
--- a/src/image-watcher.ts
+++ b/src/image-watcher.ts
@@ -14,6 +14,7 @@ import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
+import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
// ========== Types ==========
@@ -157,12 +158,15 @@ export class ImageWatcher extends EventEmitter {
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
// Ignore common heavy directories for performance
ignored: (path: string) => {
- // Skip node_modules, .git, and other heavy directories
+ // Skip node_modules, .git, and other heavy directories, and Codeman's
+ // own upload folders: a pdf the user handed to the agent is not a file
+ // the agent produced.
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
- path.includes('/.next/')
+ path.includes('/.next/') ||
+ UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
) {
return true;
}
diff --git a/src/mcp-sync-targets.ts b/src/mcp-sync-targets.ts
new file mode 100644
index 00000000..a2292f6e
--- /dev/null
+++ b/src/mcp-sync-targets.ts
@@ -0,0 +1,74 @@
+/**
+ * @fileoverview MCP sync targets that are not Codeman run modes.
+ *
+ * `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
+ * `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
+ * something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
+ * Copilot CLI is the first. They are plain data here, take part only when installed or when their
+ * config file already exists (an absent tool is reported `absent`, never created), and sort after
+ * the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
+ *
+ * @module mcp-sync-targets
+ */
+
+import { accessSync, constants as fsConstants } from 'node:fs';
+import { homedir } from 'node:os';
+import { delimiter, join } from 'node:path';
+import type { McpConfigFormat } from './config/cli-registry/types.js';
+import type { McpSyncTarget } from './mcp-sync.js';
+
+export interface McpSyncOnlyTool {
+ id: string;
+ label: string;
+ /** Home-relative default location of the MCP config file. */
+ path: string;
+ format: McpConfigFormat;
+ /** The env var the tool reads to move its home, and the file under it. */
+ relocation?: { envVar: string; path: string };
+ /** The executable whose presence on this machine means the tool is installed. */
+ binary: string;
+}
+
+export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [
+ {
+ id: 'copilot',
+ label: 'GitHub Copilot CLI',
+ path: '.copilot/mcp-config.json',
+ format: 'copilot-json',
+ // COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME= copilot mcp list` reads ).
+ relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
+ binary: 'copilot',
+ },
+];
+
+/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */
+export function binaryOnPath(name: string, env: Record = process.env): boolean {
+ const dirs = [
+ ...(env.PATH ?? '').split(delimiter).filter(Boolean),
+ join(homedir(), '.local', 'bin'),
+ '/usr/local/bin',
+ ];
+ return dirs.some((dir) => {
+ try {
+ accessSync(join(dir, name), fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+}
+
+/** The sync-only tools as sync targets, skipping any id the registry already provides. */
+export function mcpSyncOnlyTargets(
+ taken: ReadonlySet,
+ isInstalled: (binary: string) => boolean = binaryOnPath
+): McpSyncTarget[] {
+ return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({
+ id: t.id,
+ label: t.label,
+ path: t.path,
+ format: t.format,
+ ...(t.relocation ? { relocation: t.relocation } : {}),
+ installed: isInstalled(t.binary),
+ }));
+}
diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts
index 1e092ad0..de9ad0a8 100644
--- a/src/mcp-sync.ts
+++ b/src/mcp-sync.ts
@@ -12,7 +12,8 @@
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
- * `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
+ * `enabled: false`, antigravity `disabled: true`, Copilot's `disabledMcpServers` in its
+ * `settings.json`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
@@ -284,6 +285,29 @@ function toOpencode(s: McpServer): Record {
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
+/**
+ * GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
+ * `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
+ * this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
+ */
+function fromCopilot(raw: unknown): McpServer | null {
+ if (!isRecord(raw)) return null;
+ if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
+ return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
+ }
+ if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
+ return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
+ }
+ return null;
+}
+
+function toCopilot(s: McpServer): Record {
+ if (s.transport === 'stdio') {
+ return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
+ }
+ return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
+}
+
interface JsonDialect {
/** Key holding the server table. */
key: string;
@@ -291,12 +315,23 @@ interface JsonDialect {
to(s: McpServer): Record | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record;
+ /**
+ * A file beside the config that lists the names of servers the user switched off (the switch is
+ * not stored on the server entry). Read, never written.
+ */
+ disabledIn?: { file: string; key: string };
}
const JSON_DIALECTS: Record, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
+ 'copilot-json': {
+ key: 'mcpServers',
+ from: fromCopilot,
+ to: toCopilot,
+ disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
+ },
'opencode-json': {
key: 'mcp',
from: fromOpencode,
@@ -558,6 +593,32 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
+/**
+ * Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
+ * disabled, so they are not copied. If that file cannot be read as intended the target is
+ * reported unreadable rather than guessing: a guess could switch a server on everywhere.
+ */
+async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise {
+ if (format === 'codex-toml') return;
+ const companion = JSON_DIALECTS[format].disabledIn;
+ if (!companion) return;
+ const text = await readText(join(dirname(file), companion.file));
+ if (text === null || !text.trim()) return;
+ let doc: unknown;
+ try {
+ doc = JSON.parse(text);
+ } catch {
+ throw new McpConfigError(
+ `${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
+ );
+ }
+ const list = isRecord(doc) ? doc[companion.key] : undefined;
+ if (list === undefined) return;
+ const names = strArr(list);
+ if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
+ for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
+}
+
let applying = false;
/**
@@ -611,6 +672,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
+ await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
diff --git a/src/session.ts b/src/session.ts
index 107f8d20..c4501795 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -106,6 +106,7 @@ import {
getClaudeBinaryPath,
spawnPtyWithHelperRepair,
resolveLocalShell,
+ waitForProcessesExit,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
@@ -179,7 +180,10 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
-/** Graceful shutdown delay when stopping session (100ms) */
+/**
+ * Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
+ * session. A deadline, not a sleep: stop() moves on as soon as it has exited.
+ */
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
@@ -4801,8 +4805,10 @@ export class Session extends EventEmitter {
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
}
- // Give it a moment to terminate gracefully
- await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
+ // Give it a moment to terminate gracefully. For a tmux-backed session this
+ // is the attach client, gone within a few ms of SIGTERM, and this used to be
+ // a fixed 100ms sleep on every close.
+ if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
// Force kill with SIGKILL if still alive
try {
diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts
index 8678302c..9901980d 100644
--- a/src/subagent-watcher.ts
+++ b/src/subagent-watcher.ts
@@ -761,13 +761,35 @@ export class SubagentWatcher extends EventEmitter {
* by workingDir alone would kill subagents belonging to OTHER sessions.
*/
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise {
- const subagents = this.getSubagentsForSession(workingDir);
- for (const agent of subagents) {
- if (agent.status === 'active' || agent.status === 'idle') {
- // Only kill subagents belonging to this specific session
- if (sessionId && agent.sessionId !== sessionId) continue;
- await this.killSubagent(agent.agentId);
+ const targets = this.getSubagentsForSession(workingDir).filter(
+ // Only kill subagents belonging to this specific session
+ (agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
+ );
+ if (targets.length === 0) return;
+
+ // ONE process scan for the lot. This used to call killSubagent() per agent, and
+ // each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
+ // box with ~100 matching processes), so closing a session right after a workflow
+ // paid that once per recently active subagent. The match rules are
+ // findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
+ const pidMap = await this.getClaudePids();
+ const signalled = new Set();
+ for (const agent of targets) {
+ // The liveness checker may have completed it while the scan ran.
+ if (agent.status !== 'active' && agent.status !== 'idle') continue;
+ for (const [pid, procInfo] of pidMap) {
+ if (signalled.has(pid)) continue;
+ if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
+ signalled.add(pid);
+ try {
+ process.kill(pid, 'SIGTERM');
+ } catch {
+ // Process may have already exited
+ }
+ break; // one process per agent, as killSubagent() does
+ }
}
+ this.markSubagentAsCompleted(agent);
}
}
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 49db4ab0..d7974a75 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -94,7 +94,13 @@ import {
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
-import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
+import {
+ wrapWithNice,
+ SAFE_PATH_PATTERN,
+ resolveLocalShell,
+ loginShellArgs,
+ waitForProcessesExit,
+} from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -144,12 +150,18 @@ const TMUX_CREATION_WAIT_MS = 100;
const GET_PID_MAX_RETRIES = 5;
const GET_PID_RETRY_MS = 200;
-/** Delay after tmux kill command (200ms) */
+/**
+ * How long killSession gives a pane's children to exit on SIGTERM before it
+ * re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
+ */
const TMUX_KILL_WAIT_MS = 200;
-/** Delay for graceful shutdown (100ms) */
+/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
+/** How long killSession waits for every process it signalled to be gone before it warns. */
+const KILL_VERIFY_TIMEOUT_MS = 2000;
+
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
@@ -2350,6 +2362,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
+ /**
+ * {@link getPanePid} without blocking the event loop, for the kill path: a close
+ * must not stall every other session's I/O while tmux answers.
+ */
+ private async getPanePidAsync(muxName: string): Promise {
+ if (IS_TEST_MODE) return 99999;
+ if (!isValidMuxName(muxName)) {
+ console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
+ return null;
+ }
+ try {
+ const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
+ encoding: 'utf-8',
+ timeout: EXEC_TIMEOUT_MS,
+ });
+ const pid = parseInt(stdout.trim(), 10);
+ return Number.isNaN(pid) ? null : pid;
+ } catch {
+ return null;
+ }
+ }
+
/**
* Check if a tmux session exists.
*/
@@ -2604,7 +2638,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
}
- // Check if a process is still alive
+ // Check if a process is still alive. Signal decisions only: an unreaped zombie
+ // counts here, so its process group still gets the SIGKILL below. The WAITS use
+ // waitForProcessesExit(), which counts a zombie as exited.
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -2614,20 +2650,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
- // Verify all PIDs are dead, with retry
+ // Verify all PIDs are dead, returning as soon as they are
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise {
- const startTime = Date.now();
- const checkInterval = 100;
-
- while (Date.now() - startTime < maxWaitMs) {
- const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
- if (aliveCount === 0) {
- return true;
- }
- await new Promise((resolve) => setTimeout(resolve, checkInterval));
- }
-
- const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
+ const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
if (stillAlive.length > 0) {
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
}
@@ -2685,7 +2710,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (isValidMuxName(session.muxName)) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
- execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
+ await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2702,7 +2727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Get current PID (may have changed)
- const currentPid = this.getPanePid(session.muxName) || session.pid;
+ const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
@@ -2724,7 +2749,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
- await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
+ // Most children are gone within a few ms; the re-scan below still runs, to
+ // catch anything spawned since the first one.
+ await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
childPids = await this.getChildPidsFresh(currentPid);
for (const childPid of childPids) {
@@ -2742,7 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (this.isProcessAlive(currentPid)) {
try {
process.kill(-currentPid, 'SIGTERM');
- await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
+ await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
if (this.isProcessAlive(currentPid)) {
process.kill(-currentPid, 'SIGKILL');
}
@@ -2751,10 +2778,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
- // Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
+ // Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
+ // Async: tmux takes tens of ms to tear a session down, and execSync held the
+ // whole server for that long on every close.
if (isValidMuxName(session.muxName)) {
try {
- execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
+ await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2798,7 +2827,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Verify all processes are dead
- const allDead = await this.verifyProcessesDead(allPids, 2000);
+ const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
if (!allDead) {
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
}
diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts
index 56e515db..b90863cc 100644
--- a/src/tui/tui-client.ts
+++ b/src/tui/tui-client.ts
@@ -48,6 +48,12 @@ import https from 'node:https';
import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
+import {
+ basicAuthHeader,
+ parseEnvFile,
+ readCodemanCredentials,
+ type CodemanCredentials,
+} from '../codeman-credentials.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js';
@@ -281,53 +287,9 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
}
-/**
- * Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
- * lines and `#` comments skipped, one layer of matching quotes stripped.
- */
-export function parseEnvFile(text: string): Record {
- const result: Record = {};
- for (const rawLine of text.split(/\r?\n/)) {
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) continue;
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
- if (!match) continue;
- let value = match[2].trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- result[match[1]] = value;
- }
- return result;
-}
-
-export interface TuiCredentials {
- username: string;
- password?: string;
-}
-
-/**
- * Credentials for the API, env first and the data dir's `.env` as the fallback,
- * exactly like the `codeman attach` path. No password means no auth is
- * configured (or the user has it only in the server's environment, in which
- * case the API answers 401 and `connect()` reports `authRequired`).
- */
-export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
- let fileEnv: Record = {};
- try {
- fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
- } catch {
- /* absent or unreadable: env-only */
- }
- const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
- const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
- return password ? { username, password } : { username };
-}
-
-export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
- if (!credentials.password) return undefined;
- return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
-}
+// One credential reader for every client of the API (attach, tui, agent).
+export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
+export type TuiCredentials = CodemanCredentials;
// ─────────────────────────────────────────────────────────────────────────────
// Degraded mode
diff --git a/src/utils/index.ts b/src/utils/index.ts
index c7763ee5..5657511b 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -12,6 +12,7 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
+export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
diff --git a/src/utils/process-exit-wait.ts b/src/utils/process-exit-wait.ts
new file mode 100644
index 00000000..72599e96
--- /dev/null
+++ b/src/utils/process-exit-wait.ts
@@ -0,0 +1,76 @@
+/**
+ * @fileoverview Wait for processes to exit, and return as soon as they have.
+ *
+ * The session kill path used to sleep a FIXED interval after each signal (100 ms
+ * for the PTY client, 200 ms for the pane's children, 100 ms for the process
+ * group) and then verified in 100 ms steps. A process that was gone after 3 ms
+ * still cost the whole interval, so closing a tab spent most of its ~0.5 s in
+ * timers. This keeps every deadline the kill path had; it only stops waiting
+ * once there is nothing left to wait for.
+ *
+ * A zombie counts as exited. It holds nothing but its pid until the parent reaps
+ * it, and on the kill path that parent is the tmux server or the service
+ * manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
+ * zombie from a running process, so on Linux the state letter in
+ * `/proc//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';
+ }
+ // " () …": 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 {
+ 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;
+}
diff --git a/src/web/paste-image-gc.ts b/src/web/paste-image-gc.ts
index 8cee31ce..462fce35 100644
--- a/src/web/paste-image-gc.ts
+++ b/src/web/paste-image-gc.ts
@@ -1,25 +1,99 @@
/**
- * @fileoverview Periodic GC for paste-image files.
+ * @fileoverview Periodic GC for prompt-upload files, and the one place that
+ * names the directories they live in.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
- * under {workingDir}/.claude-images/. The route only triggers cleanup on
+ * under {workingDir}/.codeman-uploads/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
- * older than MAX_AGE_MS from each live session's image dir on an interval.
+ * older than MAX_AGE_MS from each live session's upload dirs on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
- * image dir.
+ * upload dir.
*/
import fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
-import { join, resolve } from 'node:path';
+import { join, resolve, sep } from 'node:path';
+import { getDataDir } from '../config/instance.js';
+import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
+/**
+ * Where a prompt upload is written, relative to the session's working
+ * directory. IN the workspace, because that is the only path that resolves
+ * identically for a local agent and a container (only the workspace is
+ * bind-mounted, at the same absolute path); hidden, so it stays out of
+ * `git status` and the agent's view of the repository; FLAT, because a nested
+ * `/.codeman/` is Codeman's own data dir when the workspace is the
+ * home directory; and self-ignoring, through a `.gitignore` of `*` the route
+ * writes once.
+ */
+export const UPLOADS_DIR = '.codeman-uploads';
+/** Where uploads landed before the move: written to by nothing, readable for one release. */
+export const LEGACY_UPLOADS_DIR = '.claude-images';
+/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
+export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
+
+/**
+ * Every directory a session's uploads sit in, current first. Both consumers
+ * act on what this returns, the hourly sweep and the recursive delete in
+ * cleanupSession(), so it lists only REAL directories (a link planted by a
+ * workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
+ * not one; readdir follows a link to a directory), none that is or contains
+ * this instance's data dir (a home workspace reaches it under a contrived
+ * instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
+ * inside one; a directory strictly below the data dir only ever holds uploads
+ * and is listed, or the uploads of a workspace like `~/.codeman/app` would
+ * never be collected), and nothing for a remote (SSH) session, whose
+ * workingDir is the remote path and would name a same-named LOCAL directory
+ * here. The working directory is a user-chosen path, so it is probed BOUNDED
+ * first (#516): one on a mount that stopped answering reads `unknown` and is
+ * skipped, never touched. The sweep keeps the probe's stall cap; the delete,
+ * acting on one path at the user's request, passes `pastCap`. The check is
+ * made when listing: a same-user process that swaps a listed directory for a
+ * link afterwards is accepted, since it already writes anywhere this process
+ * can.
+ */
+export async function uploadDirs(
+ session: { workingDir: string; remote?: unknown },
+ probe: PathProbeOptions = {}
+): Promise {
+ if (session.remote) return [];
+ if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
+ const dataDir = await realDir(getDataDir());
+ const dirs: string[] = [];
+ for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
+ if (!(await isRealDir(dir))) continue;
+ const real = await realDir(dir);
+ if (real === dataDir || dataDir.startsWith(real + sep)) continue;
+ dirs.push(dir);
+ }
+ return dirs;
+}
+
+/** lstat, so a symlink is not a directory, whatever it points at. */
+async function isRealDir(p: string): Promise {
+ try {
+ return (await fs.lstat(p)).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
+async function realDir(dir: string): Promise {
+ try {
+ return await fs.realpath(dir);
+ } catch {
+ return resolve(dir);
+ }
+}
+
export async function sweepPasteImagesOnce(
ctx: Pick,
now: number = Date.now()
@@ -28,26 +102,27 @@ export async function sweepPasteImagesOnce(
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
- const dir = join(session.workingDir, '.claude-images');
- let entries: string[];
- try {
- entries = await fs.readdir(dir);
- } catch {
- continue; // dir absent — nothing to do
- }
- for (const name of entries) {
- if (!name.startsWith('paste-')) continue;
- const p = join(dir, name);
- scanned += 1;
+ for (const dir of await uploadDirs(session)) {
+ let entries: string[];
try {
- const st = await fs.lstat(p);
- if (!st.isFile()) continue;
- if (st.mtimeMs < cutoff) {
- await fs.unlink(p);
- deleted += 1;
- }
+ entries = await fs.readdir(dir);
} catch {
- // best-effort: skip permission/race errors silently
+ continue; // gone since listed — nothing to do
+ }
+ for (const name of entries) {
+ if (!name.startsWith('paste-')) continue;
+ const p = join(dir, name);
+ scanned += 1;
+ try {
+ const st = await fs.lstat(p);
+ if (!st.isFile()) continue;
+ if (st.mtimeMs < cutoff) {
+ await fs.unlink(p);
+ deleted += 1;
+ }
+ } catch {
+ // best-effort: skip permission/race errors silently
+ }
}
}
}
@@ -55,7 +130,7 @@ export async function sweepPasteImagesOnce(
}
/**
- * The path two sessions must share to share a paste-image dir: the canonical
+ * The path two sessions must share to share an upload dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
@@ -68,7 +143,7 @@ function canonicalDir(dir: string): string {
}
}
-/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
+/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
@@ -76,11 +151,11 @@ export interface PasteImageDirUser {
}
/**
- * Does another live session still use this working directory's paste-image
- * dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
- * and several sessions routinely share one case directory, so without this
- * check closing one session deletes the pasted images a sibling in the same
- * case still refers to.
+ * Does another live session still use this working directory's upload dirs?
+ * Deleting a session removes them (`uploadDirs()`) recursively, and several
+ * sessions routinely share one case directory, so without this check closing
+ * one session deletes the pasted images a sibling in the same case still
+ * refers to.
*
* Two kinds of sibling count as live:
*
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 447a959e..dda35644 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -717,6 +717,7 @@ class CodemanApp {
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:' -> collapsed group id
this._lastTabGroupStructureKey = null;
+ this._tabRailSearch = ''; // rail search box text: in memory only, never persisted
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -1426,6 +1427,13 @@ class CodemanApp {
this.closeTileCountMenu({ refocus: true });
return;
}
+ // And so does the rail's search box while it holds text: that Escape
+ // clears the search and nothing else. This listener runs in the capture
+ // phase, before the box's own onkeydown, so the box cannot claim it there.
+ if (e.target?.id === 'tabRailSearch' && this._tabRailSearch) {
+ this.handleTabRailSearchKeydown(e);
+ return;
+ }
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
@@ -1812,8 +1820,11 @@ class CodemanApp {
_markDetached(id, on) {
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
// A popped-out session's window owns its PTY size now, so it leaves the
- // tile grid (one place per session in this browser tab).
- if (on && this._tileGrid?.has(id)) this.removeTile(id);
+ // tile grid (one place per session in this browser tab). `gone`: it left by
+ // itself, not by a tile the user removed, so the grid's count stays and the
+ // ranking fills that cell the next time the grid opens, as when it pops out
+ // with the grid closed.
+ if (on && this._tileGrid?.has(id)) this.removeTile(id, { gone: true });
const container = this.$('sessionTabs');
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.classList.toggle('detached', on);
@@ -2286,6 +2297,8 @@ class CodemanApp {
}
_onSessionCreated(data) {
+ // A session this tab is closing stays closed until the server answers.
+ if (this._closingSessions?.has(data.id)) return;
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
@@ -2310,6 +2323,9 @@ class CodemanApp {
_onSessionUpdated(data) {
const session = data.session || data;
+ // A session this tab is closing stays closed until the server answers
+ // (closeSession() puts it back if the delete is refused).
+ if (this._closingSessions?.has(session.id)) return;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
this.sessions.set(session.id, session);
@@ -2732,6 +2748,23 @@ class CodemanApp {
return;
}
+ // An in-document link (`[Install](#installation)`). The browser must not follow it: with
+ // `` a bare fragment points at the dashboard's root and would navigate the
+ // app away. Resolve it inside this rendered document and scroll there (constants.js).
+ // A fragment that matches nothing is simply ignored, never a navigation.
+ const fragmentLink = ev.target.closest('a[href^="#"]');
+ if (fragmentLink && body.contains(fragmentLink)) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const root = fragmentLink.closest('.rv-text') || body;
+ const target = window.CodemanMarkdownAnchors?.find(root, fragmentLink.getAttribute('href'));
+ if (target) {
+ const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
+ target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
+ }
+ return;
+ }
+
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
// (webview-tabs.js). Every other link keeps its new-tab default.
@@ -5738,27 +5771,163 @@ class CodemanApp {
*/
applySidebarFilter(query) {
this._sidebarFilter = (query ?? '').trim().toLowerCase();
+ this._applyTabListFilter();
+ }
+
+ /**
+ * The ONE row filter behind both search boxes: the sidebar's filter box and
+ * the vertical rail's search box (only one of the two hosts the list at a
+ * time). Classes only, over whatever the last render drew, so grouping, order,
+ * Alt+N badges and the server layout never move; the matching itself is the
+ * pure CodemanTabSearch (constants.js).
+ *
+ * - Sidebar: name (aria-label) + working directory (title), as it always has.
+ * - Rail: the NAME only, a web tab's title included (it is a row in the same
+ * list, and hiding every web tab would make a dashboard unfindable).
+ *
+ * A session row with a tab alert (red action or yellow idle, whatever
+ * tabAlerts holds, the set a collapsed group header surfaces) stays visible
+ * even when it does not match: a prompt waiting on you is never hidden by a
+ * view filter. Alerts come and go through renderSessionTabs(), and both
+ * render paths end here, so nothing else re-runs this for them.
+ *
+ * A group or case box left with nothing showing hides with its header, its
+ * count shows the rows left showing (a kept row included), and the grouped
+ * tree's roving stop and posinset follow the visible items. A collapsed
+ * group's rows are not in the DOM at all, which is why the rail search also
+ * expands the projection (_projectTabGroups). Rows that appear or disappear
+ * move the rows below them, so the connector lines are redrawn then.
+ */
+ _applyTabListFilter() {
const container = this.$('sessionTabs');
if (!container) return;
- const reachable =
- this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
- const needle = reachable ? this._sidebarFilter : '';
+ const rail = this._tabOrientation() === 'vertical';
+ const sidebarReachable =
+ !rail && this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
+ const query = rail ? this._tabRailSearch : sidebarReachable ? this._sidebarFilter : '';
+ const rows = [...container.querySelectorAll('.session-tab')].map((tab) => ({
+ key: tab,
+ text: rail
+ ? this._tabRowSearchName(tab)
+ : `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
+ section: tab.closest('.tab-layout-group, .tab-cluster'),
+ // Web tabs carry no alerts; only a session row can be kept.
+ keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
+ }));
+ const result = window.CodemanTabSearch?.filter(rows, query);
+ if (!result) return;
+ // Whether anything appeared or disappeared: the rows below it then moved.
+ let moved = false;
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
- container.classList.toggle('tabs-filtering', !!needle);
- for (const tab of container.querySelectorAll('.session-tab')) {
- if (!needle) {
- tab.classList.remove('tab-filtered-out');
- continue;
+ if (container.classList.contains('tabs-filtering') !== result.active) {
+ container.classList.toggle('tabs-filtering', result.active);
+ moved = true;
+ }
+ const setFilteredOut = (el, out) => {
+ if (el.classList.contains('tab-filtered-out') === out) return;
+ el.classList.toggle('tab-filtered-out', out);
+ moved = true;
+ };
+ for (const row of rows) setFilteredOut(row.key, result.hidden.has(row.key));
+ for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
+ const shown = result.counts.get(section) ?? 0;
+ setFilteredOut(section, result.active && shown === 0);
+ const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
+ if (!count) continue;
+ if (count.dataset.total === undefined) count.dataset.total = count.textContent;
+ const text = result.active ? String(shown) : count.dataset.total;
+ if (count.textContent !== text) count.textContent = text;
+ }
+ const empty = document.getElementById('tabRailSearchEmpty');
+ const emptyHidden = !(rail && result.active && result.matchCount === 0);
+ if (empty && empty.hidden !== emptyHidden) {
+ empty.hidden = emptyHidden;
+ moved = true;
+ }
+ // Lineage and subagent/ultracode connectors are anchored to row positions.
+ // A render redraws them itself, but a keystroke in either box only toggles
+ // classes here, so the rows it moved would leave the lines pointing at where
+ // they were. Only when something moved: an unchanged re-apply at every
+ // render tail stays free, and the call coalesces with a render's own.
+ if (moved) this.updateConnectionLines?.();
+ // Both render paths already set posinset and the roving stop over an
+ // unfiltered tree, so this second pass only runs while a search hides
+ // something or right after one changed what shows.
+ if ((moved || result.active) && container.getAttribute('role') === 'tree') {
+ const items = this._applyTabTreePositions(container);
+ const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
+ if (items.length && !items.includes(stop)) {
+ this._setTabTreeStop(container, items.find((item) => item.getAttribute('aria-selected') === 'true') || items[0]);
}
- const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
- tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
}
// The count shows visible rows, so it moves with every filter change —
// including keystrokes in the filter box, which call this directly.
this.updateSidebarCount();
}
+ /** What the rail search matches on a row: a session's name, a web tab's title. */
+ _tabRowSearchName(tab) {
+ if (tab.dataset.webviewId) return this.webviews?.get(tab.dataset.webviewId)?.name || '';
+ return tab.querySelector('.tab-name')?.dataset.fullName || '';
+ }
+
+ /** True while the vertical rail's search box is narrowing the list. */
+ _tabRailSearchActive() {
+ return this._tabOrientation() === 'vertical' && !!window.CodemanTabSearch?.needle(this._tabRailSearch);
+ }
+
+ /**
+ * The rail search box's input handler. In-memory only: never persisted, never
+ * sent anywhere. Starting or ending a search re-renders once when it changes
+ * what a collapsed group hides (the projection ignores collapse while
+ * searching); every other keystroke only re-applies the row classes.
+ */
+ setTabRailSearch(value) {
+ this._tabRailSearch = typeof value === 'string' ? value : '';
+ const clear = document.getElementById('tabRailSearchClear');
+ if (clear) clear.hidden = this._tabRailSearch.length === 0;
+ if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
+ else this._applyTabListFilter();
+ }
+
+ /** Clear button (and Escape): empty the box, restore the list, keep focus in the box. */
+ clearTabRailSearch() {
+ const input = document.getElementById('tabRailSearch');
+ if (input) input.value = '';
+ this.setTabRailSearch('');
+ input?.focus();
+ }
+
+ /**
+ * Escape in a box that holds text clears the search and nothing else. The
+ * global key handler (setupEventListeners) runs in the CAPTURE phase, before
+ * the box's inline onkeydown, so it is the one that routes the key here and
+ * returns before its close-every-panel branch; stopping propagation from the
+ * inline handler would come too late. An empty box leaves Escape to it, and
+ * an Escape that cancels an IME composition is the IME's.
+ */
+ handleTabRailSearchKeydown(event) {
+ if (event.key !== 'Escape' || event.isComposing || !this._tabRailSearch) return;
+ event.preventDefault();
+ event.stopPropagation();
+ this.clearTabRailSearch();
+ }
+
+ /**
+ * Forget the search without rendering: the list is leaving the rail
+ * (applyTabOrientation), and the render that follows draws it unfiltered.
+ */
+ _resetTabRailSearch() {
+ this._tabRailSearch = '';
+ const input = document.getElementById('tabRailSearch');
+ if (input) input.value = '';
+ const clear = document.getElementById('tabRailSearchClear');
+ if (clear) clear.hidden = true;
+ const empty = document.getElementById('tabRailSearchEmpty');
+ if (empty) empty.hidden = true;
+ }
+
// ═══════════════════════════════════════════════════════════════
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
// ═══════════════════════════════════════════════════════════════
@@ -5954,9 +6123,19 @@ class CodemanApp {
// Session Tabs
// ═══════════════════════════════════════════════════════════════
- renderSessionTabs() {
+ renderSessionTabs({ immediate = false } = {}) {
// Don't re-render while user is typing in the inline rename input
if (this._inlineRenameActive) return;
+ if (immediate) {
+ // For a change the user just made and is watching for (closing a tab). The
+ // debounce restarts on every session update, so with busy sessions around a
+ // debounced pass can lag well past its 100ms. A pass still pending would only
+ // repeat this one, so it is dropped.
+ clearTimeout(this._debounceTimers.sessionTabs);
+ this._debounceTimers.sessionTabs = null;
+ this._renderSessionTabsImmediate();
+ return;
+ }
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -6121,6 +6300,20 @@ class CodemanApp {
};
}
+ /**
+ * The session's tab row when it is painted, else null: not rendered (a
+ * collapsed group) or hidden by the rail search or the sidebar filter. A
+ * display:none row still answers getBoundingClientRect() with an all-zero
+ * rect, which is truthy, so a connector, a spawn or a genie measured from it
+ * would start at the viewport's top-left corner. Every floating window that
+ * anchors to its parent tab measures through this.
+ */
+ _paintedSessionTab(sessionId) {
+ if (!sessionId) return null;
+ const tab = document.querySelector(`.session-tab[data-id="${sessionId}"]`);
+ return tab && tab.getClientRects().length > 0 ? tab : null;
+ }
+
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
_tabConnectorPath(anchor, winRect) {
if (anchor.vertical) {
@@ -6709,7 +6902,9 @@ class CodemanApp {
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot ` 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'
? 'sh'
: ``;
@@ -6965,6 +7160,8 @@ class CodemanApp {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
+ // A group the search emptied is hidden whole, header included.
+ if (section.classList.contains('tab-filtered-out')) continue;
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
@@ -7368,7 +7565,10 @@ class CodemanApp {
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
- collapsedGroupIds: [...this.collapsedTabGroupIds],
+ // A rail search shows matches inside collapsed groups too, so it projects
+ // every group open. The stored per-device collapse state is untouched and
+ // applies again as soon as the search is cleared.
+ collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
@@ -7389,6 +7589,9 @@ class CodemanApp {
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
+ // Every group is drawn open while the rail search runs; a toggle then would
+ // change what the user sees only after the search is cleared.
+ if (this._tabRailSearchActive()) return false;
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
@@ -9639,27 +9842,32 @@ class CodemanApp {
}
async closeSession(sessionId, killMux = true) {
- // ⚠️ Captured BEFORE the await, and the delete is announced to
- // _onSessionDeleted through _closingSessions. The `session_deleted` SSE
- // broadcast for THIS delete routinely lands while the request is still in
- // flight, and that handler nulls activeSessionId and shows the welcome
- // screen. Re-reading the field after the await therefore made the fallback
- // below a coin flip: closing the tab you were on either moved you to the
- // next session or dumped you on the home screen, depending on which path
- // won the race (both outcomes measured on one build, 2026-08-17).
+ // Already on its way out (a repeated click, the mux panel racing the tab).
+ if (this._closingSessions.has(sessionId)) return;
+ // The tab goes FIRST and the server is asked after. The kill takes the server
+ // a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
+ // there that long after "Kill" read as a click that did nothing. A refused
+ // delete puts the row back below.
+ //
+ // ⚠️ Everything is read BEFORE the first await, and the delete is announced
+ // to _onSessionDeleted through _closingSessions: the `session_deleted` SSE
+ // broadcast for THIS delete arrives while the request is still in flight, and
+ // that handler must leave the follow-up selection to this method (both
+ // outcomes of that race were measured on one build, 2026-08-17).
+ const session = this.sessions.get(sessionId);
+ const orderIndex = this.sessionOrder.indexOf(sessionId);
const wasActive = this.activeSessionId === sessionId;
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
// sessionOrder entry (often not tiled, which would collapse the grid).
- // Captured here for the same reason as wasActive: the SSE delete can remove
- // the tile while the request is still in flight.
const grid = this._tileGrid;
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
this._closingSessions.add(sessionId);
+ let res = null;
try {
- await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
- this._cleanupSessionData(sessionId);
- // The last tile leaving closes the grid (no reselect): the pick below runs.
- if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
+ // The same teardown the SSE event runs (split pane, tile, detached window,
+ // WebSocket, per-session state), only now instead of when the server is
+ // done. It is idempotent, so the real event finds nothing left to do.
+ this._onSessionDeleted({ id: sessionId });
if (wasActive && grid?.open) {
// `auto`: the app chose this tile because the previous one went away.
@@ -9684,18 +9892,50 @@ class CodemanApp {
}
}
- this.renderSessionTabs();
+ this.renderSessionTabs({ immediate: true });
+ res = await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
+ } catch (err) {
+ // `res` stays null: handled below like a delete that got no answer.
+ console.warn('[closeSession] close failed:', err);
+ } finally {
+ this._closingSessions.delete(sessionId);
+ }
+
+ // 404: already gone (closed from another tab or device), which is what was asked.
+ let gone = !!res && (res.ok || res.status === 404);
+ if (!gone) {
+ // Refused, or no answer. Ask rather than guess: a delete can land on the
+ // server and still lose its reply, and its session_deleted event has then
+ // already been spent while the request was in flight.
+ const check = await this._api(`/api/sessions/${sessionId}`);
+ gone = check?.status === 404;
+ }
+
+ if (gone) {
+ // An SSE resync (handleInit) that landed mid-request rebuilds the list from
+ // the server, which still had the session then.
+ if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
- } catch (err) {
- this.showToast('Failed to close session', 'error');
- } finally {
- this._closingSessions.delete(sessionId);
+ return;
}
+
+ // Still on the server (or the server is unreachable, in which case the resync
+ // on reconnect has the last word): its row comes back where it was.
+ if (session && !this.sessions.has(sessionId)) {
+ this.sessions.set(sessionId, session);
+ if (!this.sessionOrder.includes(sessionId)) {
+ const at = orderIndex === -1 ? this.sessionOrder.length : Math.min(orderIndex, this.sessionOrder.length);
+ this.sessionOrder.splice(at, 0, sessionId);
+ this.saveSessionOrder();
+ }
+ this.renderSessionTabs();
+ }
+ this.showToast('Failed to close session', 'error');
}
// Request confirmation before closing a session
@@ -9828,8 +10068,10 @@ class CodemanApp {
try {
await this._apiDelete('/api/sessions');
- // Every tiled session is gone: nothing left to remember or reselect.
- this.closeTileGrid?.({ keepStored: false, reselect: false });
+ // Every tiled session is gone: nothing to reselect. The stored grid is
+ // kept like every other close; it now names only gone sessions, so the
+ // next Tiles click ranks the open sessions from scratch.
+ this.closeTileGrid?.({ keepStored: true, reselect: false });
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 2272378d..8a1d500f 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -110,6 +110,9 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
+const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
+const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
+const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
@@ -1017,6 +1020,56 @@ function tabClusterNameSplit(name, label) {
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
+/**
+ * Session-list search: the vertical rail's search box and the sidebar's filter
+ * box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
+ * Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
+ * Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
+ * @param {unknown} query
+ * @returns {string} the needle, '' when there is nothing to search for
+ */
+function tabSearchNeedle(query) {
+ return typeof query === 'string' ? query.trim().toLowerCase() : '';
+}
+
+/**
+ * Which rows a search hides. Pure: the caller reads the rows off the list it
+ * rendered and applies the result as classes, so the list itself (grouping,
+ * order, Alt+N badges) is never rebuilt or reordered by a search.
+ *
+ * A row flagged `keep: true` is never hidden, matching or not (the caller keeps
+ * a tab with an alert on screen: a prompt waiting on you is never hidden by a
+ * view filter). It counts toward its section, so its group stays on screen with
+ * it, but not toward `matchCount`.
+ *
+ * @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
+ * in list order; `section` is the row's group or case box, null/undefined for none.
+ * @param {unknown} query
+ * @returns {{active: boolean, hidden: Set, counts: Map, matchCount: number}}
+ * `counts` is the rows left showing per section (kept rows included), every
+ * section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
+ * number of rows whose TEXT matched, so it can be 0 above a lone kept row.
+ */
+function filterTabSearchRows(rows, query) {
+ const needle = tabSearchNeedle(query);
+ const hidden = new Set();
+ const counts = new Map();
+ let matchCount = 0;
+ for (const row of Array.isArray(rows) ? rows : []) {
+ const hasSection = row.section !== null && row.section !== undefined;
+ if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
+ const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
+ const matches = !needle || text.includes(needle);
+ if (!matches && row.keep !== true) {
+ hidden.add(row.key);
+ continue;
+ }
+ if (matches) matchCount++;
+ if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
+ }
+ return { active: needle.length > 0, hidden, counts, matchCount };
+}
+
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1298,6 +1351,63 @@ function cleanCopiedSelection(text, options) {
return lines.join('\n');
}
+// ── Markdown heading anchors ────────────────────────────────────────────────
+// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
+// nothing to jump to. And with `` a bare `#installation` href points at the dashboard's
+// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
+// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
+// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
+// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
+
+/**
+ * GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
+ * dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
+ */
+function markdownHeadingSlug(text) {
+ return String(text ?? '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
+ .replace(/ /g, '-');
+}
+
+/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
+function assignMarkdownHeadingAnchors(root) {
+ const used = new Set();
+ for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
+ const base = markdownHeadingSlug(heading.textContent);
+ let slug = base;
+ for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
+ used.add(slug);
+ heading.dataset.mdAnchor = slug;
+ }
+}
+
+/**
+ * The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
+ * points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
+ * slug, then a heading the author wrote an explicit ``/`id` for, looked up INSIDE `root`
+ * only (never `document.getElementById`, which could find an app element of the same name).
+ */
+function findMarkdownAnchorTarget(root, href) {
+ let fragment = String(href ?? '').replace(/^#/, '');
+ try {
+ fragment = decodeURIComponent(fragment);
+ } catch {
+ /* a malformed escape: use it as written */
+ }
+ if (!fragment) return root;
+ assignMarkdownHeadingAnchors(root);
+ const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
+ for (const heading of root.querySelectorAll('[data-md-anchor]')) {
+ if (wanted.includes(heading.dataset.mdAnchor)) return heading;
+ }
+ for (const el of root.querySelectorAll('[id]')) {
+ if (el.id === fragment) return el;
+ }
+ return null;
+}
+
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -1355,6 +1465,10 @@ if (typeof window !== 'undefined') {
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
+ window.CodemanTabSearch = {
+ needle: tabSearchNeedle,
+ filter: filterTabSearchRows,
+ };
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1367,6 +1481,11 @@ if (typeof window !== 'undefined') {
window.CodemanCopySelection = {
clean: cleanCopiedSelection,
};
+ window.CodemanMarkdownAnchors = {
+ slug: markdownHeadingSlug,
+ assign: assignMarkdownHeadingAnchors,
+ find: findMarkdownAnchorTarget,
+ };
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
@@ -2083,11 +2202,11 @@ function tileGridCapacity({ width, height }) {
}
/**
- * The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
- * deleted, detached and duplicate ids are dropped, the list is capped at
- * TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
- * must be 1 to 3 finite positive numbers. Anything that is not a v1 object
- * (or its JSON) gives null.
+ * The stored grid (`codeman:tile-grid`: session ids and the layout, never
+ * content) made safe to apply: unknown, deleted, detached and duplicate ids
+ * are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
+ * name a kept id, and track fractions must be 1 to 3 finite positive numbers.
+ * Anything that is not a v1 object (or its JSON) gives null.
*
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
@@ -2095,11 +2214,20 @@ function tileGridCapacity({ width, height }) {
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
*
+ * `freed` names the cells whose session no longer exists (or was popped out
+ * to its own window) since the grid was stored: the ranking fills those first
+ * when the grid comes back (restoreTileGridCells). A hole the user left empty
+ * is not freed. `count` is how many tiles the grid had after the user's own
+ * last change (a session that went away by itself does not lower it), so the
+ * grid comes back to that many when there are sessions to fill it; a value
+ * stored before it existed, or a malformed one, reads as the number of
+ * sessions the stored cells name.
+ *
* @param {unknown} raw - the parsed value, or the stored JSON string
* @param {{has(id: string): boolean}|Iterable} liveSessions - ids that exist now
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
- * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
- * zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
+ * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
+ * focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
*/
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
let value = raw;
@@ -2110,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
const ids = [];
const cells = [];
+ const freed = [];
+ const named = [];
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
- const keep =
- typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
- ids.length < TILE_GRID_MAX;
+ const isId = typeof id === 'string' && id !== '';
+ const present = isId && live.has(id) && !detachedIds?.has?.(id);
+ const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
if (keep) ids.push(id);
+ // Its session went away since: the cell is freed for the ranking to fill.
+ if (isId && !present && !named.includes(id)) freed.push(cells.length);
+ if (isId && !named.includes(id)) named.push(id);
// A malformed entry (not a string, not null) is a hole too.
cells.push(keep ? id : null);
}
@@ -2122,11 +2255,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
};
+ const count =
+ Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
+ ? value.count
+ : Math.min(named.length, TILE_GRID_MAX);
return {
v: 1,
open: value.open === true && ids.length > 0,
ids,
cells,
+ freed,
+ count,
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
colFr: fractions(value.colFr),
@@ -2134,6 +2273,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
};
}
+/**
+ * A stored grid as it comes back (the Tiles button, a page reload): its cells
+ * exactly as stored, holes the user left included, and its focus (or the tile
+ * it had zoomed). Only when it holds fewer tiles than its `count` (sessions
+ * that went away since, or by themselves while it was open) does it fill, from
+ * `ranked` (best first, never a session already in it): the freed cells first,
+ * then the other empty cells, in reading order; more than the cells hold join
+ * after them (the shape grows when the grid lays them out, reformTileCells).
+ * A cell stays empty only when no other session is left to place. Never
+ * trimmed to the window: a grid larger than the window fits shows its focused
+ * tile alone until the window fits it again, and the arrangement stays.
+ *
+ * @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
+ * focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
+ * @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
+ * @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
+ */
+function restoreTileGridCells(stored, ranked) {
+ const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
+ const cells = [];
+ for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
+ const tiles = cells.filter(Boolean);
+ if (tiles.length === 0) return null;
+ const focusedId =
+ [stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
+ const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
+ const fillers = [];
+ for (const id of ranked || []) {
+ if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
+ }
+ const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
+ const empty = [];
+ cells.forEach((id, k) => {
+ if (id === null) empty.push(k);
+ });
+ // Freed cells first, each group in reading order.
+ empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
+ let count = tiles.length;
+ for (const k of empty) {
+ if (count >= target || fillers.length === 0) break;
+ cells[k] = fillers.shift();
+ count++;
+ }
+ const extra = fillers.slice(0, Math.max(0, target - count));
+ return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
+}
+
/**
* New track fractions after a divider drag (grid-template `fr` values): the two
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
@@ -2166,7 +2352,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
/**
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
- * ones a count fills a grid with), in tab order: live ones only, never a session popped out to
+ * ones a count fills a grid with), in the order given (tab order, or the
+ * ranking): live ones only, never a session popped out to
* its own window (that window owns its PTY size). A session with no PTY
* attached IS offered: its tile shows the Attach overlay.
*
@@ -2180,44 +2367,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
for (const id of sessionOrder) {
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
- if (!session) continue;
+ if (!session || result.some((r) => r.id === id)) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
+// Ranking groups (rankTileSessions): working first, then the sessions waiting
+// on the user, then everything else.
+const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
+
+/**
+ * The stamp a session is ranked by inside its group. A WORKING session keys
+ * off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
+ * about once a second, so its last-activity stamp is always "now", and one
+ * that never submitted would otherwise claim the head of the group. 0 means
+ * unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
+ * the last byte the pane printed, i.e. when it went quiet).
+ */
+function tileRankStamp(row) {
+ if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
+ return sessionActivityAnchor(row);
+}
+
+/**
+ * Which open sessions the tile grid shows when nobody said which (the Tiles
+ * button with no stored grid to bring back, and every place the grid fills a
+ * tile on its own: a count picked in its menu, a freed cell), best first
+ * (owner request: "prefer to load in tiles that are working and then the most
+ * recent, so the oldest don't get opened"):
+ * 1. WORKING, the most recently started turn first;
+ * 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
+ * permission or question dialog; `waiting`: a finished turn not seen
+ * yet), most recent first;
+ * 3. then every other one (idle, done, error), most recently active first.
+ * Inside a group the newest stamp wins (tileRankStamp) and a session with no
+ * stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
+ * the result never shuffles. The states are the home screens' own
+ * (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
+ * order differs: the home screens put the longest-running turn first, the grid
+ * the most recent.
+ *
+ * Pure. Unit-tested in test/tile-grid-ranking.test.ts.
+ *
+ * @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
+ * @returns {string[]} the ids, best first, each once
+ */
+function rankTileSessions(rows) {
+ const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
+ const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
+ list.sort((a, b) => {
+ const byGroup = group(a) - group(b);
+ if (byGroup !== 0) return byGroup;
+ const atA = tileRankStamp(a);
+ const atB = tileRankStamp(b);
+ if (atA !== atB) {
+ if (!atA) return 1;
+ if (!atB) return -1;
+ return atB - atA;
+ }
+ const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
+ const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ });
+ const ids = [];
+ for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
+ return ids;
+}
+
/**
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
* (owner decision 8). In order:
* a. the grid this tab last had (`stored`, already sanitized: live, not
- * detached, at most the cap), if any of its sessions survive;
+ * detached, at most the cap), if any of its sessions survive, exactly
+ * as it was (restoreTileGridCells: its cells and holes, a cell its
+ * session freed filled from the ranking);
* b. else an open split's two sessions, Pane A focused;
- * c. else the open sessions in tab order (buildTilePickerSessions: no
- * detached ones), up to `limit`, the active session always among them and focused
- * (when it sits past the limit, the first `limit - 1` others come with it).
+ * c. else the open sessions in `ranked` order (rankTileSessions: working,
+ * then needing input, then the most recent; tab order when no ranking is
+ * given), detached ones never, up to `limit`, the active session always
+ * among them and focused (when it ranks past the limit, the first
+ * `limit - 1` others come with it).
* Null when there is nothing to open.
*
- * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
- * split?: string[]|null, sessions: Map, sessionOrder: string[],
+ * @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
+ * focused: string|null, zoomed: string|null}|null,
+ * split?: string[]|null, ranked?: string[]|null, sessions: Map, sessionOrder: string[],
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
- * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
+ * @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
+ * focusedId: string|null}|null}
*/
-function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
- if (stored?.ids?.length) {
- const focus = stored.zoomed || stored.focused;
- return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
- }
+function tileGridOpenSet({
+ stored = null,
+ split = null,
+ ranked = null,
+ sessions,
+ sessionOrder,
+ detachedIds,
+ activeId = null,
+ limit,
+}) {
+ const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
+ (c) => c.id
+ );
+ const restored = stored ? restoreTileGridCells(stored, all) : null;
+ if (restored) return { source: 'stored', ...restored };
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
const pair = (split || []).filter(usable);
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
- const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
if (all.length === 0) return null;
let ids = all.slice(0, max);
if (all.includes(activeId) && !ids.includes(activeId)) {
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
}
- return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
+ return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
}
/**
@@ -2237,8 +2501,9 @@ function sanitizeTileCount(raw) {
* `base` (what the grid would open, or what an open grid shows, in its order)
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
- * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
- * with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
+ * tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
+ * the app passes the ranking, rankTileSessions) with the ones not in it yet.
+ * Fewer sessions than `n` give fewer tiles.
*
* @param {string[]} base
* @param {string[]} all
@@ -2747,6 +3012,8 @@ if (typeof window !== 'undefined') {
fitTileCells,
cycleTile,
tileGridOpenSet,
+ restoreTileGridCells,
+ rankTileSessions,
sanitizeTileCount,
tileGridSetForCount,
tileCellCols,
diff --git a/src/web/public/entrance-animations.js b/src/web/public/entrance-animations.js
index 2fa94275..a7068e0a 100644
--- a/src/web/public/entrance-animations.js
+++ b/src/web/public/entrance-animations.js
@@ -1,12 +1,13 @@
/**
- * @fileoverview Entrance animations for the four things that appear when work
+ * @fileoverview Entrance animations for the things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
- * agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
- * One picker per surface, plus themes that set all four to a matching look.
+ * agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
+ * the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
+ * that set all five to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
- * → Appearance → Entrance Animations.
+ * → Animations.
*
* Four constraints shape the design:
*
@@ -34,13 +35,28 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
+ * Tiles are off by default (`settle`, the grid's own quick fade, exactly as
+ * before) and switched on in App Settings → Animations → Tile Animations, or
+ * preset by a theme. A styled tile plays in two beats that combine two
+ * surfaces. The FRAME enters as it mounts, in its own style
+ * (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
+ * first capture lands (`.tile-body.term-enter`, the same keyframes as the main
+ * pane). The load queue serves one capture at a time, so
+ * the screens light up one after another, the focused tile first. The frame
+ * styles move transform and opacity only (six tiles animate at once; a blur
+ * belongs to the serialized screen beat), and each has its own way out on the
+ * closing grid's still copy. A reload restores with `settle` whatever the
+ * setting.
+ *
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
- * `data-line-anim` on ; the keyframes live in styles.css. `?animlab=1`
- * opens a floating picker that fakes tabs, a pane replay, a window and a line,
- * so styles can be compared without spawning real sessions or agents.
+ * `data-line-anim` / `data-tile-anim` on ; the keyframes live in
+ * styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
+ * replay, a window and a line, and replays the tile grid in place, so styles
+ * can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
- * @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
+ * @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
+ * tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
- { key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
+/**
+ * Tile grid entrance styles, for each tile's FRAME (its screen plays the
+ * TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
+ * a wash on ::before: FitAddon reads the untransformed layout box, so a tile
+ * still fits once at its final size (#464). `stagger` is the default gap
+ * between tiles and `order` the default cascade: `reading` (row by row),
+ * `wave` (diagonals from the top left) or `ripple` (outward from the focused
+ * tile). `from`: the tile is measured against a source after layout (its tab,
+ * the Tiles button), so it is held one frame and timed by _runTileEntrances.
+ */
+// prettier-ignore
+const TILE_ANIM_STYLES = [
+ { key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
+ { key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
+ { key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
+ { key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
+ { key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
+ { key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
+ { key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
+ { key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
+ { key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
+];
+
+/** Cascade orders for the tile grid; `auto` is each style's own. */
+const TILE_ANIM_ORDERS = [
+ { key: 'auto', label: 'Style default' },
+ { key: 'reading', label: 'Reading order' },
+ { key: 'wave', label: 'Diagonal wave' },
+ { key: 'ripple', label: 'Ripple from focus' },
+];
+
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
+/** Gap between tiles leaving, in reading order, so the last copy ends last. */
+const TILE_EXIT_STAGGER_MS = 35;
+
+/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
+const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
+
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
- { key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
- { key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
- { key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
- { key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
- { key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
- { key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
+ { key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
+ { key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
+ { key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
+ { key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
+ { key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
+ { key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
+ { key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
];
+/**
+ * The surfaces a theme is recognised by. A theme also PRESETS the tile style
+ * when it is picked, but the tile style is its own setting (App Settings →
+ * Animations → Tile Animations, off by default), so changing it afterwards
+ * does not turn the theme into "Custom".
+ */
+const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
+
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
- * hook short-circuits on its first line. Opt in via App Settings → Appearance →
- * Entrance Animations, which persists to the localStorage keys below.
+ * hook short-circuits on its first line. Opt in via App Settings → Animations,
+ * which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
+/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
+const TILE_ANIM_DEFAULT = 'settle';
+const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
+ tile: 'codeman:tileAnim',
+ tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
+ // Off (the grid's own `settle`) until chosen: a theme saved before tiles
+ // were a surface gives them nothing new.
+ this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
+ this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
+ setTileAnimStyle(key, { persist = true } = {}) {
+ // prettier-ignore
+ this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
+ },
+
+ /** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
+ setTileAnimOrder(key, { persist = true } = {}) {
+ this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
+ if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
+ this._syncAnimLab?.();
+ },
+
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
+ this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
- /** The theme matching the four current styles, or 'custom' for a lab mix. */
+ /** The current style of each surface, keyed as ANIM_SURFACES. */
+ _currentAnimStyles() {
+ return {
+ tab: this._tabAnimStyle,
+ win: this._winAnimStyle,
+ line: this._lineAnimStyle,
+ term: this._termAnimStyle,
+ tile: this._tileAnimStyle,
+ };
+ },
+
+ /** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
currentAnimTheme() {
- const match = ANIM_THEMES.find(
- (t) =>
- t.tab === this._tabAnimStyle &&
- t.win === this._winAnimStyle &&
- t.line === this._lineAnimStyle &&
- t.term === this._termAnimStyle
- );
+ const current = this._currentAnimStyles();
+ const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
return match ? match.key : 'custom';
},
- // ── App Settings picker ───────────────────────────────────────────────────
+ // ── App Settings → Animations ─────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
- if (!sel) return;
- sel.value = this.currentAnimTheme();
- if (!sel.dataset.bound) {
- sel.dataset.bound = '1';
- sel.addEventListener('change', () => {
- // 'custom' is a readout of a lab mix, not something you can select into.
- if (sel.value === 'custom') sel.value = this.currentAnimTheme();
- else this.setAnimTheme(sel.value);
+ if (sel) {
+ sel.value = this.currentAnimTheme();
+ if (!sel.dataset.bound) {
+ sel.dataset.bound = '1';
+ sel.addEventListener('change', () => {
+ // 'custom' is a readout of a lab mix, not something you can select into.
+ if (sel.value === 'custom') sel.value = this.currentAnimTheme();
+ else this.setAnimTheme(sel.value);
+ });
+ }
+ }
+ // App Settings → Animations → Animation Lab. Settings has no unsaved-edit
+ // tracking, so this closes it as Cancel does (the row says so).
+ const labBtn = document.getElementById('appSettingsOpenAnimLab');
+ if (labBtn && !labBtn.dataset.bound) {
+ labBtn.dataset.bound = '1';
+ labBtn.addEventListener('click', () => {
+ this.closeAppSettings?.();
+ this.openAnimLab();
});
}
+ // Tile Animations: its own row, off (`settle`) by default. A theme picked
+ // above presets it; picked here, it applies to tiles alone.
+ const tileSel = document.getElementById('appSettingsTileAnim');
+ if (tileSel) {
+ tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
+ if (!tileSel.dataset.bound) {
+ tileSel.dataset.bound = '1';
+ tileSel.addEventListener('change', () => {
+ this.setTileAnimStyle(tileSel.value);
+ this._syncEntranceAnimSetting();
+ });
+ }
+ }
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
+ _tileAnimDuration() {
+ return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
+ },
+
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -501,6 +618,317 @@ Object.assign(CodemanApp.prototype, {
}
},
+ // ── Tile grid ─────────────────────────────────────────────────────────────
+
+ /** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
+ tileEntranceStyle() {
+ return this._tileAnimStyle || TILE_ANIM_DEFAULT;
+ },
+
+ /**
+ * Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
+ * until the next frame, when its cell is final and _runTileEntrances can
+ * order it and measure it against its source. `setBackstop(ms)` arms the
+ * mount's own timer that ends the entrance should animationend never come
+ * (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
+ * so a frame that never comes cannot strand a tile invisible.
+ */
+ _stageTileEntrance(el, sessionId, setBackstop) {
+ el.classList.add('tile--enter-themed', 'tile--enter-hold');
+ (this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
+ setBackstop(4000);
+ if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
+ },
+
+ /**
+ * One frame after the tiles mounted, every cell is final (openTileGrid packs,
+ * then a stored grid moves its tiles back). Each held tile gets its delay
+ * from the cascade order and, for `fly`/`deal`, the offset that starts it on
+ * its tab or the Tiles button (FLIP: transform only, so the fit it already
+ * did at its real size stands). `beam` draws its lines instead.
+ */
+ _runTileEntrances() {
+ this._tileEnterRaf = 0;
+ const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
+ this._tileEnterQueue = [];
+ if (queue.length === 0) return;
+ const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
+ const speed = this._animSpeed || 1;
+ const stagger = def.stagger / speed;
+ const duration = this._tileAnimDuration();
+ const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
+ const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
+ const ranks = this._tileEnterRanks(
+ queue.map((q) => q.sessionId),
+ order
+ );
+ const beams = [];
+ queue.forEach((item, k) => {
+ const { el, sessionId } = item;
+ const delay = ranks[k] * stagger;
+ el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
+ if (def.from) {
+ const to = el.getBoundingClientRect();
+ const from = this._tileSourceRect(def.from, sessionId);
+ if (from && to.width > 0 && to.height > 0) {
+ if (def.key === 'beam') beams.push({ from, to, delay });
+ else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
+ if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
+ }
+ }
+ el.classList.remove('tile--enter-hold');
+ // When the frame lands, for a screen whose content arrives earlier.
+ el._tileEnterEndsAt = performance.now() + delay + hold + duration;
+ item.setBackstop(delay + hold + duration + 900);
+ });
+ if (beams.length > 0) this._drawTileBeams(beams);
+ },
+
+ /**
+ * Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
+ * row by row, `wave` by diagonal (row + column), `ripple` by distance from
+ * the focused tile. Equal keys share a step, so a diagonal lands together.
+ */
+ _tileEnterRanks(ids, order) {
+ const grid = this._tileGrid;
+ const cols = Math.max(1, grid?.cols || 1);
+ const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
+ const pos = ids.map((id, k) => {
+ const c = cellOf(id);
+ return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
+ });
+ let keys;
+ if (order === 'wave') {
+ keys = pos.map((p) => p.row + p.col);
+ } else if (order === 'ripple') {
+ const f = cellOf(grid?.focusedId);
+ const fr = f < 0 ? 0 : Math.floor(f / cols);
+ const fc = f < 0 ? 0 : f % cols;
+ keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
+ } else {
+ keys = pos.map((p) => p.cell);
+ }
+ const steps = [...new Set(keys)].sort((a, b) => a - b);
+ return keys.map((v) => steps.indexOf(v));
+ },
+
+ /** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
+ _tileSourceRect(kind, sessionId) {
+ const visible = (node) => {
+ const r = node?.getBoundingClientRect?.();
+ if (!r || !(r.width > 0 && r.height > 0)) return null;
+ return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
+ };
+ if (kind === 'tab') {
+ const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
+ const r = visible(tab);
+ if (r) return r;
+ }
+ return visible(document.querySelector('.btn-tile-grid'));
+ },
+
+ /**
+ * The transform that puts a tile laid out at `to` onto `from`, as custom
+ * properties `-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
+ * size for `fly` (it grows out of it), a small card turned a little for
+ * `deal`.
+ */
+ _setTileFlight(el, from, to, kind, rank, prefix) {
+ const dx = from.left + from.width / 2 - (to.left + to.width / 2);
+ const dy = from.top + from.height / 2 - (to.top + to.height / 2);
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
+ let sx;
+ let sy;
+ let rot = 0;
+ if (kind === 'deal') {
+ sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
+ rot = [-14, 10, -7, 13, -11, 8][rank % 6];
+ } else {
+ sx = clamp(from.width / to.width, 0.02, 1);
+ sy = clamp(from.height / to.height, 0.02, 1);
+ }
+ el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
+ el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
+ el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
+ el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
+ el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
+ },
+
+ /** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
+ _flashTileSourceTab(sessionId, delay) {
+ const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
+ if (!tab) return;
+ tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
+ tab.classList.remove('tab-launch');
+ void tab.offsetWidth;
+ tab.classList.add('tab-launch');
+ const onEnd = (e) => {
+ if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
+ };
+ const done = () => {
+ clearTimeout(timer);
+ tab.removeEventListener('animationend', onEnd);
+ tab.classList.remove('tab-launch');
+ tab.style.removeProperty('--tab-launch-delay');
+ };
+ const timer = setTimeout(done, delay + 900);
+ tab.addEventListener('animationend', onEnd);
+ },
+
+ /**
+ * `beam`: a line draws from each tile's tab (or the Tiles button) down into
+ * the middle of its tile, in the connection-line look, with a packet riding it
+ * when the line style is `packet`; the tile materializes as it lands. Its
+ * own overlay: the agent lines' one is rebuilt from scratch on every redraw.
+ * The overlay goes once every line has faded.
+ */
+ _drawTileBeams(beams) {
+ const ns = 'http://www.w3.org/2000/svg';
+ let svg = document.getElementById('tileBeamLines');
+ if (!svg) {
+ svg = document.createElementNS(ns, 'svg');
+ svg.id = 'tileBeamLines';
+ svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
+ svg.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(svg);
+ }
+ const packet = this._lineAnimStyle === 'packet';
+ let last = 0;
+ for (const { from, to, delay } of beams) {
+ const x1 = from.left + from.width / 2;
+ const y1 = from.bottom;
+ // Into the tile's middle: its top edge sits right under the tab strip,
+ // so a beam aimed there ran sideways along the strip instead of down.
+ const x2 = to.left + to.width / 2;
+ const y2 = to.top + to.height / 2;
+ const midY = (y1 + y2) / 2;
+ const path = document.createElementNS(ns, 'path');
+ path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
+ path.setAttribute('class', 'connection-line tile-beam-line');
+ svg.appendChild(path);
+ const len = Math.max(1, Math.round(path.getTotalLength()));
+ path.style.setProperty('--line-len', `${len}px`);
+ path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
+ if (packet) {
+ const dot = path.cloneNode(false);
+ dot.setAttribute('class', 'connection-line-packet');
+ svg.appendChild(dot);
+ }
+ last = Math.max(last, delay);
+ }
+ clearTimeout(this._tileBeamTimer);
+ this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
+ },
+
+ /**
+ * A tile's screen lights up when its first capture lands (tile-grid.js load
+ * queue), in the terminal pane's style: the same keyframes as the main pane,
+ * on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
+ * tile at a time, since the queue serves one capture at a time), so the
+ * xterm inside keeps its size and its fit.
+ */
+ playTileScreenEntrance(body) {
+ if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
+ body._codemanScreenDone?.();
+ // Content that lands while the frame is still flying in waits (held at
+ // its first keyframe, so hidden) until the frame has nearly landed: two
+ // beats, frame then screen, rather than both at once.
+ const frame = body.closest?.('.tile');
+ const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
+ const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
+ body.style.setProperty('--tile-screen-delay', `${wait}ms`);
+ body.classList.remove('term-enter');
+ void body.offsetWidth;
+ body.classList.add('term-enter');
+ let timer = null;
+ const done = (e) => {
+ if (e && (e.target !== body || e.pseudoElement)) return;
+ clearTimeout(timer);
+ body.removeEventListener('animationend', done);
+ body.removeEventListener('animationcancel', done);
+ body.classList.remove('term-enter');
+ body.style.removeProperty('--tile-screen-delay');
+ if (body._codemanScreenDone === done) body._codemanScreenDone = null;
+ };
+ body._codemanScreenDone = done;
+ body.addEventListener('animationend', done);
+ body.addEventListener('animationcancel', done);
+ // Backstop, as the main pane's: a backgrounded tab never fires animationend.
+ timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
+ },
+
+ /**
+ * The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
+ * frame style's own way: `fly` back into each tab, `deal` gathered into the
+ * Tiles button, `crt` switched off to a dot, and so on. Copies leave in
+ * reading order, so the last one ends last (the layer goes on its
+ * animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
+ * covers tiles that stay. Returns how long the slowest copy takes, for the
+ * layer's fallback timer, or 0 for the default fade.
+ */
+ _stageTileExit(copies, { now = false } = {}) {
+ const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
+ const ms = TILE_EXIT_MS[style];
+ if (now || !ms || copies.length === 0) return 0;
+ const speed = this._animSpeed || 1;
+ const def = TILE_ANIM_STYLES.find((s) => s.key === style);
+ copies.forEach(({ ghost, el, sessionId }, k) => {
+ ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
+ if (style !== 'fly' && style !== 'deal') return;
+ const at = el.getBoundingClientRect();
+ const target = this._tileSourceRect(def.from, sessionId);
+ if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
+ });
+ return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
+ },
+
+ /**
+ * Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
+ * light up again in queue order): no remount, reconnect or resize. With the
+ * grid closed it opens it, the real path.
+ */
+ _demoTiles() {
+ const grid = this._tileGrid;
+ if (!grid?.open) {
+ if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
+ else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
+ return;
+ }
+ if (!this._tileMotionAllowed?.()) return;
+ const ids = grid.ids.slice();
+ ids.forEach((id, k) => {
+ const entry = grid.tiles.get(id);
+ if (entry) this._replayTileEntrance?.(entry.el, id, k);
+ });
+ // The screens, as the load queue would land them: focused first, then reading order.
+ const speed = this._animSpeed || 1;
+ const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
+ const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
+ clearTimeout(this._tileDemoTimer);
+ const timers = order.map((id, k) =>
+ setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
+ );
+ this._tileDemoTimers?.forEach(clearTimeout);
+ this._tileDemoTimers = timers;
+ },
+
+ /** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
+ _demoTilesRoundTrip() {
+ if (!this._tileGrid?.open) {
+ this._demoTiles();
+ return;
+ }
+ this.toggleTileGrid?.();
+ clearTimeout(this._tileDemoTimer);
+ this._tileDemoTimer = setTimeout(
+ () => {
+ if (!this._tileGrid?.open) this.toggleTileGrid?.();
+ },
+ 1400 / (this._animSpeed || 1)
+ );
+ },
+
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
+ ${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
+