diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 9d6c3cb8..1d5433b4 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.29.0",
+ "version": "1.29.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c10b5a5..c2740d9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# aicodeman
+## 1.29.1
+
+### Patch Changes
+
+- 5b920cb: Auto-name sessions from the first prompt (#376, opt-in). With the new synced **Auto-name Sessions** setting on (App Settings → Appearance → Tabs, default off), a tab that still carries its generated name takes a title from the first real prompt you submit, keeping the case prefix: `w3-myapp` becomes `w3-myapp: fix the login redirect`. The strip shows the title with the prefix in the tooltip, and the next session in that case still counts up. It happens once per session, only for prompts you type or send through the input API (never a Ralph, respawn, cron or approval answer), never for shells, and a name you set yourself is never touched. Slash commands such as `/clear` do not become titles. The title is derived locally from the prompt's first sentence; no text leaves the machine. `nameSource` (`placeholder` / `auto` / `manual`) is a new additive field on session state.
+
+ Landed with the fixes the review of #376 asked for: first prompt only (not every prompt), a user-input gate so Ralph, respawn, cron and approval writes cannot name a tab, the prefix form so the case identity and `w` counter survive, and a keystroke tracker that handles a bare Esc, bracketed pastes, wheel reports, Tab and history recall instead of mis-titling the tab.
+
+ ### Thanks
+ - @shenlvkang-collab for #376, the auto-naming idea and the ownership plumbing (`nameSource`, the listener wiring, the restore path) it shipped with.
+
## 1.29.0
### Minor Changes
diff --git a/CLAUDE.md b/CLAUDE.md
index 7ef88de4..4ef118e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,7 +77,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.29.0 (must match `package.json`)
+**Version**: 1.29.1 (must match `package.json`)
## Project Overview
@@ -233,6 +233,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
+**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF; #376): a placeholder tab (`w3-myapp`) takes its first real prompt as a title, in the `: ` form (`w3-myapp: fix the login redirect`) that `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip and that `_nextCaseSessionStartNumber()` still counts, so the case identity and the `w` counter survive. Ownership is the tri-state `SessionState.nameSource`: `placeholder` (Codeman's own `w-` or no name, inferred by `isGeneratedSessionName()` when a persisted state predates the field), `auto` (titled once), `manual` (the `name` setter, i.e. `PUT /api/sessions/:id/name`, which auto-naming never touches again). ⚠️ **First prompt means the FIRST**: `applyAutoName()` flips a placeholder to `auto` whether or not the string changed, so a later "1" cannot rename the tab; a prompt that yields no title (`/clear`, a `!` shell escape) leaves the session eligible for the next one. ⚠️ **Only user-originated input counts** (`SessionWriteOptions.fromUser`, set by the browser WS path and `POST /api/sessions/:id/input` ONLY, so a forgotten flag on a new path fails toward not naming): Ralph kick-starts, respawn `/clear`s, cron launches, approval answers and the trust-dialog keys write through the same `write()`/`writeViaMux()` and used to name every Ralph tab "Read @ralph_prompt.md…". A `startMode: 'shell'` CLI never feeds the tracker (a capability, not an id check; a shell tab was renamed after every `ls`), and the send-key route's Shift+Enter line feed bypasses the session entirely, so it calls `trackUserInput()` or the two lines join with no separator. ⚠️ **The tracker (`session-auto-name.ts`, pure) sits on the raw keystroke stream**, so every key has an explicit rule: a bare Esc is resolved at the END of the chunk it arrives in (it used to stay in escape mode and eat the next prompt's first character, or a whole CJK prompt); SGR mouse reports, Tab, cursor keys and Shift+Tab leave the draft alone (a wheel tick mid-word used to drop the first half); Up/Down and Ctrl+P/N/R TAINT the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines are newlines IN the composer, never Enter. The title is the first sentence past a minimum length ("e.g. fix this now" is not "e.g."), capped at 72 code points, and the composed name honours `MAX_SESSION_NAME_LENGTH`. The listener (`session-listener-wiring.ts`) checks eligibility BEFORE reading the setting, so an already-named session costs no settings read per prompt. Opt-in because the prompt lands in the tab name, `mux-sessions.json`, every `session:updated` and `/api/search` (Read My Mind keeps prompts 0600 for the same reason). Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
+
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-` / `dscbot-` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). ⚠️ **Transcript history is THREE stores, not one**, because each CLI keeps its conversations in its own: Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions` and codex's `~/.codex/sessions` (#386). Rows fold into their owning session via the `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice; that field is named for Claude and carries whatever id the CLI names its conversation with, which for every non-Claude row diverges from the Codeman id by construction. ⚠️ **`resumeId` is set by a SCANNER row only, never by a live session**, and that is what makes it safe to resume on: a row carrying one is a conversation already on disk, so `resumeHistorySession()` sends `codexConfig.resumeSessionId` and a row without one is a genuinely fresh session. Every surface that re-projects these rows has to carry the field through, the phone overview included, or a tap on that surface silently starts a second conversation. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
diff --git a/README.md b/README.md
index 27ba7db2..df93d622 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Mission control for AI coding agents
- Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • OMP • Terminal - One Dashboard • Any Device
+ Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Terminal - One Dashboard • Any Device
@@ -27,7 +27,7 @@
-**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, 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.
+**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.
Get started in one line (macOS & Linux, Windows via WSL):
@@ -42,7 +42,7 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
-- **One dashboard, eight CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
+- **One dashboard, nine CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
@@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
-You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
+You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
```bash
codeman web
@@ -82,7 +82,7 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
-**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
+**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. After updating, run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
@@ -212,7 +212,7 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack)
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
-- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling
+- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling; on a folding phone (iPhone Duo) dialogs stay clear of the hinge, and opening or closing the device is never mistaken for the keyboard
```bash
codeman web --https
@@ -255,7 +255,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
-| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). |
+| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -263,7 +263,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 3. Read the dashboard
-- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices).
+- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet.
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
@@ -271,8 +271,10 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
- **Paste or drag-and-drop images** directly into the session.
-- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop).
-- **Attachments** — register external files/docs and preview Office/PDF inline.
+- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
+- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view.
+- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
+- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it.
### 5. Make it autonomous
@@ -291,7 +293,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 7. Operate & maintain
-- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
+- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance animations, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
- **Deploy your own changes** — see [Development](#development).
@@ -439,16 +441,21 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
-- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
-- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
-- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
+- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
+- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
+- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
+- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
+- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
-- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
+- **Voice input** — dictate prompts with Deepgram Nova-3, or through this machine's Claude Code login with no API key at all (App Settings → Voice; Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
-- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
+- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
+- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones
+- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order
+- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in entrance animations for tabs, agent windows, the terminal pane and connection lines
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:` so multi-host setups stay unambiguous
---
@@ -461,8 +468,9 @@ Run a case inside its own hardened Docker container instead of directly on your
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
-- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
-- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
+- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
+- **Attach to a container you already run** — tick **Attach to an existing container** on the Docker panel to link a case to it instead of creating one. Codeman only `exec`s into it and never starts, stops, restarts or removes it; one adopted container can back several cases at different directories, and **copy an existing case** pre-fills the form from a sibling. Admin-only in multi-user mode, since the container's mounts belong to whoever started it.
+- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
@@ -478,6 +486,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
+- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
@@ -647,8 +656,8 @@ These run for **every** request — before auth, even on the default no-password
### Input, files & headers
-- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` env-prefix allowlist gates which settings each CLI can receive
-- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
+- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
+- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
### Supply chain & isolation
@@ -698,6 +707,10 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
| `Ctrl/Cmd +` / `-` | Font size |
| `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) |
+| `Shift+drag` | Select text in a pane whose mouse events go to the CLI |
+| Right-click | Copy the selection (the native menu stays when nothing is selected) |
+| `Shift+Wheel` | Scroll the local scrollback while the wheel is forwarded to the CLI |
+| `Ctrl+Z` | Swallowed in agent sessions so a running CLI cannot be suspended; normal job control in a shell |
| `Escape` | Close panels & modals |
---
@@ -762,7 +775,7 @@ Those `DONE__` strings are the skill's **split marker** trick, and
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [`SKILL.md`](skills/codeman/SKILL.md) | Safety rules, the ready-made fast path (spawn N workers, task them, collect), and the verb index. Always loaded. |
| [`reference/verbs.md`](skills/codeman/reference/verbs.md) | The 14 verbs in detail: readiness, send-and-wait, markers, interrupts, cleanup. On demand. |
-| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 6 worked multi-worker flows (fan-out, blocked-worker watch, messaging fan-out). On demand. |
+| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. |
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. |
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. |
@@ -798,8 +811,8 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
5. **`/api/v1/*`** is a stable alias of `/api/*`.
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
-7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
-7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
+7. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Those two come from hooks (Claude Code's own, and the DeepSeek Harness status bridge); `shell` and the other external CLIs (opencode/codex/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
+8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
### Recipes
@@ -866,9 +879,20 @@ curl -sG "$API/api/sessions/$SID/wait-output" \
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=60000' | jq '.data.wait'
-# 5. Read the terminal back. ⚠️ Use terminal?tail=, NOT /output: the latter's
-# textOutput is empty for every tmux-backed (i.e. every interactive) session.
-# tail counts BYTES, and what comes back is terminal data, ANSI included.
+# 5. Read the answer. claude / codex / deepseek sessions have last-response: it comes
+# from the transcript, not the screen, so no TUI frames or repaint noise.
+# ⚠️ Poll rather than read once: the transcript lands slightly after the stop
+# signal, so a read right after send-and-wait returns often comes back empty.
+for _ in $(seq 1 10); do
+ TXT=$(curl -s "$API/api/sessions/$SID/last-response" | jq -r '.data.text')
+ [ -n "$TXT" ] && break; sleep 1
+done
+printf '%s\n' "$TXT"
+
+# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp) have no transcript:
+# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
+# is empty for every tmux-backed (i.e. every interactive) session. tail counts
+# BYTES, and what comes back is terminal data, ANSI included.
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
# 6. Stream live events (session output, agent activity, status)
@@ -914,7 +938,7 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
## API
-REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
+REST over Fastify — **~230 handlers across 25 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
### Sessions
@@ -925,11 +949,13 @@ REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE str
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`: `clientId`+`seq` = exactly-once; `wait` blocks until the turn ends) |
| `GET` | `/api/sessions/:id/terminal` | Read terminal output (`?tail=`, `?full=1`); the read path for interactive sessions |
| `GET` | `/api/sessions/:id/output` | Parsed one-shot output (`textOutput` is empty for tmux-backed sessions) |
+| `GET` | `/api/sessions/:id/last-response` | The last answer as clean text, read from the transcript (claude, codex, deepseek) |
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
+| `POST` | `/api/sessions/:id/custom-model` | Restart the session's CLI on a saved custom endpoint (`{endpointId, modelId}`; `{clear: true}` returns to the native backend) |
| `DELETE` | `/api/sessions/:id` | Delete session |
### Respawn
@@ -978,6 +1004,7 @@ REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE str
| `GET` | `/api/system/update/check` | Check for a new release |
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
+| `GET` / `POST` | `/api/model-endpoints` | List / save custom OpenAI-compatible endpoints (`PUT` / `DELETE` `/:id`; admin-only in multi-user mode) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
> **Building something on top of Codeman?** [`docs/extending-codeman.md`](docs/extending-codeman.md) is the integration guide: render your own UI as a tab, subscribe to the SSE event stream to react when an agent needs you, drive Codeman from a script, and the traps worth knowing before you start. Codeman has no plugin runtime on purpose, so an integration is just your own process talking HTTP.
@@ -1014,8 +1041,8 @@ flowchart TB
end
subgraph External["External"]
- CLI["AI CLI Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi"]
- CLI["AI CLI Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP"] BG["Background Agents (Task tool)"]
+ CLI["AI CLI Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP"]
+ BG["Background Agents (Task tool)"]
end
end
@@ -1081,7 +1108,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
[](https://www.npmjs.com/package/xterm-zerolag-input)
-Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 175 tests.
+Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
```bash
npm install xterm-zerolag-input
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 966884ba..ed56cd41 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -5,7 +5,7 @@
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
index eb9b772a..316c7fc3 100644
--- a/docs/architecture-invariants.md
+++ b/docs/architecture-invariants.md
@@ -114,6 +114,20 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
⚠️ **`data-agent-id="lineage:"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
+### Auto-named sessions (first prompt → tab title)
+
+**Shipped opt-in, in the prefix form, after a review round that found five ways the first cut named a tab wrong** (#376, 1.30.0). The contributed version renamed on EVERY prompt (`applyAutoName` never left the eligible state, so "fix the login bug" then "1" left the tab named **1**), fed its tracker from every write path (shell tabs renamed after each command, every Ralph and respawn tab named "Read @ralph_prompt.md and follow the instructions."), stayed in escape mode after a bare Esc until a byte in `0x40-0x7e` arrived (Esc then "fix the login bug" submitted **ix the login bug**, Esc then a CJK prompt submitted nothing and the following prompt lost its first character, Esc then digits grew the escape buffer to 19001 characters), treated the newlines inside a bracketed paste as Enter, cleared the draft on ANY CSI (including the SGR wheel reports Codeman forwards to claude ≥ 2.1.187, so "fix the " + wheel + "login bug" gave **login bug**) and on Tab (the `@` completer), and replaced the whole name, which dropped the case from the tab and reset `_nextCaseSessionStartNumber()` so every new session in the case became `w1-` again. Each of those is a named rule in `session-auto-name.ts` with a test.
+
+**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w-` / `s-`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
+
+**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` line feed never passes through the session.
+
+**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
+
+**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in.
+
+**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
+
### Full-scrollback replay
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[;
H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to `session.terminalBuffer`, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (`hasVisibleContent`) returns `''`, because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where `_replayWouldShrinkBuffer` cannot see it. ⚠️ **"One line per screen row" holds only where no row was hard-wrapped**: `-J` joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption `_estimateReplayRows` already documents. Tests: `test/tmux-capture-full-history.test.ts` covers the cursor move, the trim pairing and `hasVisibleContent`; `test/routes/session-routes.test.ts` covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`.
diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md
index 6c34f274..eb013b29 100644
--- a/docs/wiki/Agent-CLIs.md
+++ b/docs/wiki/Agent-CLIs.md
@@ -1,9 +1,9 @@
# Agent CLIs
-Codeman drives seven run modes: six agent CLIs plus a plain shell. This page covers picking
+Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking
one, setting it up, and the differences that actually change how you work.
-## The seven modes
+## The ten modes
| Mode | CLI | Get it |
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
@@ -13,6 +13,9 @@ one, setting it up, and the differences that actually change how you work.
| **Gemini** | `gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) |
| **Antigravity** | `agy` | [antigravity.google](https://antigravity.google) |
| **Pi** | `pi` | [pi.dev](https://pi.dev) |
+| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
+| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
+| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
| **Terminal / Shell** | your `$SHELL` | Already installed. |
Any combination works, including all of them. The run mode is chosen per session from the
@@ -47,8 +50,12 @@ If a CLI is installed but a Run button for it never appears:
precisely to avoid this; a hand-written plist or unit will not.
3. Restart the server after installing a new CLI.
-`pi` is additionally version-probed rather than trusted by name, because `pi` is a generic
-enough command that something else on your PATH may answer to it.
+`pi`, `grok`, `omp` and `dsh` are additionally identity-probed rather than trusted by name:
+`pi` and `omp` are generic enough that something else on your PATH may answer to them,
+`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
+status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`) that reports
+the path and version that actually resolved, so a misresolution is visible rather than
+presenting as "the mode just does not work".
## Claude is the reference mode
@@ -62,15 +69,15 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
-| Precise idle detection (hook-driven) | Yes | Output-stabilization fallback, coarser |
+| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
-| Approvals Inbox | Yes | No |
+| Approvals Inbox | Yes | DeepSeek yes; others no |
| Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No |
| Model, effort, and ultracode controls | Yes | No |
-| `stop` and `blocked` wait signals | Yes | 400 if you ask for them explicitly |
+| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
Everything that makes a session a session works everywhere. What is Claude-only is mostly
@@ -124,6 +131,11 @@ Two behaviours that are deliberate and worth knowing:
- **The wheel is not forwarded** into its transcript. Codex ignores the mouse reports
Codeman would send, so forwarding produced a dead wheel. Scrolling in a Codex session is
local scrollback.
+- **Work detection is Codex's own.** Codex declares its `›` composer glyph and its
+ `esc to interrupt` working line, so it gets the same screen-checked idle detection Claude
+ does; before 1.26.1 every Codex session reported idle for its whole life. Codex
+ conversations also appear in Past Sessions and can be resumed, and on phones the keyboard
+ bar grows `⇧←` / `⇧→` for Codex's queued-message editing and prompt stack.
### Gemini
@@ -157,6 +169,60 @@ Pi needs the opposite instincts from every other CLI here.
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
+### Grok Build
+
+xAI's `grok`, installed with `curl -fsSL https://x.ai/cli/install.sh | bash` into
+`~/.grok/bin`. Codex-shaped on permissions and OpenCode-shaped on rendering:
+
+- **Its bypass switch is `--always-approve`**, Grok's own `bypassPermissions` mode, and the
+ Run button sends it the way it sends Codex's. In multi-user mode a user without a grant
+ has it stripped.
+- **Authentication is Grok's own**: browser OAuth on first run (a device-code screen inside
+ a Codeman pane), `grok login --device-auth` for headless hosts, or `XAI_API_KEY` as a
+ per-session environment override.
+- It renders a full-screen TUI, so scrolling is local scrollback.
+
+Guide: [`docs/grok-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/grok-integration.md).
+
+### DeepSeek Harness
+
+The mode wired least like the others, for two reasons worth knowing before you use it.
+
+**`dsh` is a launcher, not an agent.** It boots a *profile*, and the three DeepSeek ships
+(`web`, `headless`, `base`) cannot drive a terminal pane. So "installed" and "runnable" are
+different questions: the Run menu offers **DeepSeek** only once a pane-capable profile
+exists, and until then shows **DeepSeek — add a terminal profile…**, which installs the
+community `dsh-tui` with one click (`pnpm` must be on PATH, because the launcher spawns it
+directly).
+
+**Permissions are an environment variable, not a flag.** The harness has no
+skip-permissions switch. `DSH_PERMISSION_MODE` (`read-only`, `workspace-write`,
+`danger-full-access`) is the whole control, and it is the one setting Codeman deliberately
+carries as an environment variable, because the harness reads it as a soft boot-time
+default. In multi-user mode a user without a grant is clamped to `workspace-write`.
+
+The reward for the odd wiring: **DeepSeek is the one non-Claude mode with real signals.**
+Its terminal front door reports idle, working and blocked to Codeman, so a DeepSeek
+session gets precise idle detection, the `stop` and `blocked` wait signals, and Approvals
+Inbox items. Answers are read from the harness's own transcript on disk rather than
+scraped off the pane. The model is not a session setting; it is part of the profile.
+
+Guide: [`docs/deepseek-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/deepseek-integration.md).
+
+### OMP
+
+Oh My Pi, installed with `curl -fsSL https://omp.sh/install | sh` into `~/.local/bin`.
+OMP owns its auth, provider routing and approval mode entirely in `~/.omp`: there is no
+Codeman-side login, key field, or bypass switch. Run `omp` once outside Codeman to finish
+its own onboarding, and every session started through Codeman inherits that config. Its
+documented default approval mode is `yolo`, so an OMP pane auto-approves tool use with no
+flag from Codeman; change that in OMP's own config, not here.
+
+OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
+same conversation with `--continue`.
+
+Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
+
### Terminal / Shell
A plain shell in a tmux session. No agent, no hooks, no idle detection.
@@ -180,9 +246,15 @@ respawns. Which variables are accepted depends on the mode:
| Gemini | `GEMINI_*`, `GOOGLE_*` |
| Antigravity | `ANTIGRAVITY_*` |
| Pi | `PI_*` |
+| Grok | `GROK_*`, `XAI_*` |
+| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
+| OMP | `OMP_*` |
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
-is one global list, so widening it for one CLI widens it for all of them.
+is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
+the keys that could redirect a CLI's traffic or move its config home (`DSH_PERMISSION_MODE`,
+`DSH_HOME`, `DEEPSEEK_BASE_URL`, `OMP_AUTH_BROKER_URL`, and the base URLs and config
+directories of the others) are dropped for a user without the bypass grant.
Two things that deliberately do **not** travel as environment variables: **effort**, because
an environment variable hard-locks it and blocks `/effort`, and **model**, which is written
@@ -192,9 +264,11 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
-- **Codex, OpenCode, Gemini, Antigravity** when you prefer that agent or that model. You get
- the session layer, respawn, cron, Docker, and remote SSH; you do not get the hook-driven
- features.
+- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that
+ model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
+ hook-driven features.
+- **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one
+ non-Claude mode that reports idle, working and blocked to Codeman itself.
- **Pi** if you want a fast, unsandboxed agent and you understand what project trust does.
- **Shell** for the times you want a terminal on your phone with no agent at all. It is a
genuinely useful mode, not a fallback.
diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md
index f99e6796..892e594f 100644
--- a/docs/wiki/Contributing.md
+++ b/docs/wiki/Contributing.md
@@ -108,7 +108,7 @@ Conventions for wiki pages:
- Images are referenced from the main repository over raw URLs rather than being copied into
the wiki.
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
-- Label Claude-only behaviour every time it appears. Six of the seven run modes are not
+- Label Claude-only behaviour every time it appears. Nine of the ten run modes are not
Claude.
## Conduct
diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md
index 5110e5ef..0afd077d 100644
--- a/docs/wiki/Core-Concepts.md
+++ b/docs/wiki/Core-Concepts.md
@@ -50,10 +50,10 @@ A session carries state the case does not:
## Run mode
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
-`antigravity`, `pi`, or `shell`. It is chosen at start and does not change afterwards; to
+`antigravity`, `pi`, `grok`, `deepseek`, `omp`, or `shell`. It is chosen at start and does not change afterwards; to
switch, start another session.
-Claude is the reference mode. Six of the seven are not Claude, and a number of Codeman
+Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman
features are Claude-only for structural reasons rather than missing effort: they depend on
Claude Code's hook system or on parsing its terminal output. Every such feature is labelled
Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place.
@@ -68,8 +68,8 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
-This matters because it is a common source of confusion: Docker is **not** an eighth run
-mode. All seven run modes work in all three locations. A case is docker-backed or
+This matters because it is a common source of confusion: Docker is **not** an eleventh run
+mode. All ten run modes work in all three locations. A case is docker-backed or
ssh-backed; a session is claude or codex or shell.
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
@@ -155,9 +155,11 @@ report events back: a permission prompt appeared, the turn finished, the agent w
task completed. Those events drive tab alerts, the Approvals Inbox, notifications, and the
wait primitives.
-This is why some features are Claude-only. The other CLIs have no equivalent hook system,
-so for them Codeman falls back to watching terminal output, which is coarser: it can see
-that something happened, not what it was.
+This is why some features are Claude-only. The one partial exception is DeepSeek Harness,
+whose terminal front door reports idle, working and blocked to Codeman over the harness's
+own supervisor contract, so it gets the hook-driven signals without a hook file. The other
+CLIs have no equivalent, so for them Codeman falls back to watching terminal output, which
+is coarser: it can see that something happened, not what it was.
See [Hooks And Integrations](Hooks-And-Integrations).
@@ -167,7 +169,7 @@ See [Hooks And Integrations](Hooks-And-Integrations).
| --------------- | ---------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
-| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, shell. |
+| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator**| A phased plan driven across multiple agents. |
@@ -178,6 +180,6 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Read next
- [The Dashboard](The-Dashboard) - what the UI is showing you.
-- [Agent CLIs](Agent-CLIs) - the seven run modes in detail.
+- [Agent CLIs](Agent-CLIs) - the ten run modes in detail.
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
diff --git a/docs/wiki/Docker-Cases.md b/docs/wiki/Docker-Cases.md
index 76c226d1..ff5ccca1 100644
--- a/docs/wiki/Docker-Cases.md
+++ b/docs/wiki/Docker-Cases.md
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
reproducible toolchain, and for the ability to pick the whole environment up and move it to
another machine.
-A docker case is a **location overlay**, not a run mode. All seven run modes work inside a
+A docker case is a **location overlay**, not a run mode. All ten run modes work inside a
container. See [Core Concepts](Core-Concepts).
## One-time setup: the base image
@@ -26,7 +26,7 @@ A zero exit code proves the layers ran, not that the toolchain works. Verify:
```bash
docker run --rm codeman/agent:base bash -lc \
- 'for c in claude codex gemini opencode agy pi; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
+ 'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
The image is secret-free. Credentials are delivered at runtime, never baked in, so exports
@@ -79,6 +79,25 @@ Exactly one long-lived container per case, shared by every session in it.
conversation** from the bind-mounted transcript.
- Deleting the case removes the container. The workspace on the host survives.
+## Attaching to a container you already run
+
+Tick **Attach to an existing container** on **Add Case → Docker** to link a case to a
+container that already exists instead of creating one. Codeman only `exec`s into it and
+never creates, starts, stops, restarts or removes it, so a container that is missing or
+stopped fails with a message rather than being fixed for you. Drift detection does not
+apply (the container carries no Codeman configuration label). The full-image export is
+refused, since it would `docker commit` someone else's container, and the workspace export
+skips the pause that keeps an owned container consistent during the capture.
+
+One adopted container can back several cases at different in-container directories, and
+**copy an existing case** pre-fills the form from a sibling on the same container. An exact
+twin (the same container and the same directory) is refused, as is a container another
+user adopted.
+
+Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own container
+with one bind mount that has already been checked; an adopted container's mounts belong to
+whoever started it, and one that mounts `/` hands the adopter the host.
+
## Credentials
Your existing host logins work inside the container without logging in again. Credentials
@@ -92,10 +111,12 @@ the container instead.
Bind mounts are excluded from image capture, so exports stay secret-free.
-One consequence worth knowing: Pi's credentials are seeded per file rather than as a whole
-directory, because that directory also holds sessions, extensions, and installed packages,
-which can be gigabytes. So in-container Pi sessions are invisible from the host, and `pi -c`
-inside a docker case sees only that container's history.
+One consequence worth knowing: Pi, Grok and OMP credentials are seeded per file rather than
+as whole directories, because those directories also hold sessions, extensions, downloads and
+installed packages, which can be gigabytes. So in-container Pi and Grok sessions are
+invisible from the host (`pi -c` and `grok -c` inside a docker case see only that
+container's history). OMP's `sessions/` is the exception and is shared read-write, because
+Codeman reads it host-side for history and resume.
## Isolation
diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md
index e324a3af..25faa321 100644
--- a/docs/wiki/Driving-Codeman-From-An-Agent.md
+++ b/docs/wiki/Driving-Codeman-From-An-Agent.md
@@ -54,7 +54,10 @@ create-time sweep would yank the skill out from under other live sessions sharin
directory. Remove them per case with `codeman skill uninstall --case `.
The skill ships with the verb index always loaded, plus on-demand references for the verbs,
-worked multi-worker recipes, endpoint tables, and cross-session messaging.
+worked multi-worker recipes, endpoint tables, and cross-session messaging. It drives
+DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alpha
+beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
+completion signals.
## The manual path
@@ -92,8 +95,9 @@ Read these before writing any code. Each one has cost somebody an afternoon.
5. **Wait instead of polling, and a timeout is not an error.** The wait endpoints answer
`200` with `wait.timedOut: true`. Loop over short waits rather than one long call, because
tunnels cut idle connections.
-6. **Only `claude` sessions emit `stop` and `blocked`.** They come from Claude Code hooks.
- Shell and the external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
+6. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Claude's come from
+ Claude Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and
+ the other external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
explicitly there is a `400`, while omitting `until` is always safe. On a shell session
`idle` fires **once at startup and never again**, so synchronize hook-less sessions with an
output marker instead.
@@ -130,7 +134,10 @@ curl -s -X POST "$API/api/sessions/$ID/input" \
# Or wait for a marker in the output, which works on shell sessions too
curl -s "$API/api/sessions/$ID/wait-output?contains=DONE_17909&from=buffer" | jq
-# Read the terminal back
+# Read the last answer as clean text (claude, codex, deepseek sessions)
+curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text'
+
+# Or read the terminal back
curl -s "$API/api/sessions/$ID/terminal?tail=4000" | jq -r '.data.output'
# Clean up, by exact id
@@ -157,7 +164,13 @@ Make it unique per call, because tmux repaints replay old screen text.
### Reading output
-Use `terminal?tail=`, not `/output`. The latter's text field is empty for every tmux-backed
+For `claude`, `codex` and `deepseek` sessions, read the answer from the transcript rather
+than the screen: `GET /api/sessions/:id/last-response` returns the last reply as clean text
+with no TUI frames or repaint noise. Poll it briefly rather than reading once, because the
+transcript lands slightly after the `stop` signal, so a read immediately after send-and-wait
+returns often comes back empty.
+
+For everything else, use `terminal?tail=`, not `/output`. The latter's text field is empty for every tmux-backed
session, which is every interactive session. `tail` counts **bytes**, and what comes back is
terminal data with ANSI sequences included.
diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md
index bdbdf26b..45c7514b 100644
--- a/docs/wiki/FAQ.md
+++ b/docs/wiki/FAQ.md
@@ -21,6 +21,12 @@ No. Codeman drives agent CLIs you have already installed and logged in yourself.
subscription or key that CLI uses is what pays for the tokens. Codeman never collects,
stores, or refreshes your credentials.
+### Which agent CLIs does it support?
+
+Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness and
+OMP, plus a plain shell, chosen per session. Claude is the reference mode and a few features
+are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
+
### Does Codeman send my code or prompts anywhere?
No. There is no telemetry, no analytics, and no phone-home. The only network traffic
diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md
index 46bf7dc6..e31c0dc8 100644
--- a/docs/wiki/HTTP-API.md
+++ b/docs/wiki/HTTP-API.md
@@ -66,14 +66,14 @@ self-signed certificate, add `-k`.
## Endpoint map
-Roughly 200 handlers across 24 route modules. By domain:
+Roughly 235 handlers across 26 route modules. By domain:
| Domain | Handlers | Covers |
| ------------------- | -------- | --------------------------------------------------- |
-| System | 45 | Status, settings, search, digest, updates. |
-| Sessions | 34 | Create, input, terminal, wait, kill. |
-| Cases | 29 | Create, link, clone, remote and docker cases. |
-| Files | 16 | Preview, edit, raw, attachments, path picker. |
+| System | 56 | Status, settings, digest, updates, tunnel. |
+| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
+| Cases | 34 | Create, link, clone, remote and docker cases. |
+| Files | 17 | Preview, edit, raw, attachments, path picker. |
| Orchestrator | 10 | Plans and phases. |
| Ralph | 9 | Loop control and configuration. |
| Cron | 9 | Jobs and run history. |
@@ -82,10 +82,12 @@ Roughly 200 handlers across 24 route modules. By domain:
| Respawn | 7 | Respawn configuration and presets. |
| Webviews | 6 | Saved dashboards, plus the proxy. |
| Mux | 5 | tmux operations. |
+| Custom model endpoints | 5 | Saved OpenAI-compatible endpoints, and applying one to a session. |
| Push | 4 | Web push subscriptions. |
| Read My Mind | 4 | Intent profiles and prediction. |
| Scheduled | 4 | The legacy scheduled-run concept. |
-| Approvals | 3 | The inbox and answering. |
+| Approvals | 4 | The inbox, answering, acknowledging. |
+| Tab layout | 2 | Named tab groups per owner. |
| Teams, me, search, hooks, clipboard, telemetry, voice, ws | 1-2 each | |
Each route module documents its own endpoints in its file header.
@@ -114,12 +116,13 @@ Three semantics that break callers who assume otherwise:
`wait-output` matches a **literal substring, never a regex.** That is deliberate: no regex
means no catastrophic backtracking on attacker-influenced output.
-Only `claude` sessions emit `stop` and `blocked`, because those come from Claude Code hooks.
-Shell and external CLI sessions accept `idle`, `working`, and `exit`.
+Only `claude` and `deepseek` sessions emit `stop` and `blocked`: Claude's come from Claude
+Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and the other
+external CLI sessions accept `idle`, `working`, and `exit`.
## SSE
-`GET /api/events` is the live event stream. 156 event names, kept in sync between server and
+`GET /api/events` is the live event stream. 158 event names, kept in sync between server and
client with a test that fails on drift.
The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because
@@ -141,6 +144,12 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
curl -s "$API/api/subagents" | jq # background agents
curl -s "$API/api/search?q=deploy" | jq # cross-session search
+
+# with ID set to a session id:
+curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
+curl -s "$API/api/model-endpoints" | jq # saved custom OpenAI-compatible endpoints
+curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: application/json' \
+ -d '{"endpointId":"local-llama","modelId":"qwen3-27b"}' | jq # restart the CLI on that endpoint; {"clear":true} undoes it
```
## Limits
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 1153d61b..6f469007 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -5,8 +5,8 @@
Mission control for AI coding agents
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
-can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, or
-Pi inside persistent tmux sessions, streams the real terminal to the browser, and keeps
+can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
+Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
subscription limit resets, runs jobs on a schedule, and shows every background subagent
live.
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
**Already running it**
-- [Agent CLIs](Agent-CLIs) - the seven run modes, their setup, and which features are Claude-only.
+- [Agent CLIs](Agent-CLIs) - the ten run modes, their setup, and which features are Claude-only.
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
@@ -122,7 +122,7 @@ codeman web # then open http://localhost:3000
| OS | macOS or Linux. Windows works through WSL2. |
| Node.js | 22 or newer. |
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
-| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi. Plain shell sessions need none. |
+| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP. Plain shell sessions need none. |
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
diff --git a/docs/wiki/Hooks-And-Integrations.md b/docs/wiki/Hooks-And-Integrations.md
index 08c1efa8..832e0be6 100644
--- a/docs/wiki/Hooks-And-Integrations.md
+++ b/docs/wiki/Hooks-And-Integrations.md
@@ -19,9 +19,11 @@ terminal into something that can notify you.
| `teammate_idle` | An agent-team member goes idle. | Team surfaces. |
| `task_completed` | A task finishes. | Task tracking, run summary. |
-This is why several Codeman features are Claude-only. The other CLIs have no hook system, so
-for them Codeman watches terminal output, which reveals that something happened but not what
-it was.
+This is why several Codeman features are Claude-only. The one partial exception is DeepSeek
+Harness, whose terminal front door reports idle, working and blocked to Codeman over the
+harness's own supervisor contract, so it gets the hook-driven surfaces without any hook
+file. The other CLIs have no equivalent, so for them Codeman watches terminal output, which
+reveals that something happened but not what it was.
### How hooks get installed
@@ -67,7 +69,7 @@ sit beside the agents with no code at all. See [Web Tabs](Web-Tabs).
### 2. SSE events
`GET /api/events` streams everything Codeman knows: session lifecycle, output, agent
-activity, approvals, cron runs. 155 named events, stable under semantic versioning.
+activity, approvals, cron runs. 158 named events, stable under semantic versioning.
This is the seam for anything that reacts. A bot that pings your chat channel when an agent
needs a human is a short script over this stream.
diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md
index 2942f26a..a873a504 100644
--- a/docs/wiki/Input-And-Voice.md
+++ b/docs/wiki/Input-And-Voice.md
@@ -27,6 +27,15 @@ The result is the property you want on a phone: a connection that drops mid-prom
loses the prompt and never delivers it twice. Two browser tabs on the same session coexist,
and only a reconnect from the *same* tab supersedes the old connection.
+## Selecting and copying
+
+Agent CLIs hold the mouse: clicks and drags are reported into the transcript rather than
+selecting text. `Shift+drag` starts a selection anyway, right-click copies it (with nothing
+selected the native context menu is left alone), and `Ctrl+Shift+C` copies without ever
+interrupting. **Auto Copy Selection** in **App Settings → Terminal & Input**, off by
+default, copies the moment you release the mouse. On phones, long-press selects; see
+[Mobile Guide](Mobile-Guide).
+
## Zero-lag local echo
On touch devices, keystrokes are painted in the terminal immediately and sent when you press
@@ -55,6 +64,8 @@ reconcile against the real buffer and only apply while the cursor is on the comp
Chinese, Japanese, and Korean input needs an IME, and an IME needs a real text field.
Turning on CJK input in **App Settings → Terminal & Input** puts an always-visible textarea
below the terminal that owns composition, then delivers the composed text to the session.
+Ctrl- and Alt-modified navigation keys typed through it reach the CLI as the modified
+sequences, so word jumps and history keys keep working.
## Voice dictation
diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md
index 476a528c..b8968a4c 100644
--- a/docs/wiki/Installation.md
+++ b/docs/wiki/Installation.md
@@ -9,7 +9,7 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
| **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. |
| **Node.js 22+** | The installer offers to install it if missing. |
| **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. |
-| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
+| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
Codeman itself sends no telemetry and phones no home. The only network traffic is your
browser to your server, and whatever the agent CLI you chose does on its own.
@@ -20,13 +20,16 @@ browser to your server, and whatever the agent CLI you chose does on its own.
curl -fsSL https://getcodeman.com/install | bash
```
-This installs Node.js and tmux if they are missing, clones Codeman into `~/.codeman/app`,
-and builds it.
+This installs Node.js, tmux and a build toolchain if they are missing (node-pty ships no
+Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
+builds it.
What it asks you:
1. **Permission for every system change.** Package installs and agent CLI downloads are
- prompted individually. Nothing is installed silently.
+ prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
+ offers to install any of them (DeepSeek excepted: its npm package installs only a
+ launcher with no runnable profile), or you skip and install one yourself later.
2. **How the dashboard should be reachable.** Three choices:
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
@@ -101,6 +104,21 @@ at server start, so markup changes need a restart.
See [Contributing](Contributing) for the rest of the development loop.
+## Route D: Docker Compose
+
+Codeman itself can run in a container and spawn Docker cases as sibling containers through
+the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set
+`CODEMAN_PASSWORD`, then:
+
+```bash
+bash docker/Start-Codeman.sh
+```
+
+Run the script again after updating rather than a plain `docker compose up`, so the rebuilt
+image, the refreshed volumes and the entrypoint arrive together. The full guide, including
+storage and networking options, is
+[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
+
## Installing an agent CLI
Codeman drives CLIs, it does not bundle them. Install at least one:
@@ -113,6 +131,9 @@ Codeman drives CLIs, it does not bundle them. Install at least one:
| **Antigravity** | See [antigravity.google](https://antigravity.google) | Google's successor to the consumer Gemini CLI. |
| **Gemini CLI** | See [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | Enterprise only since Google's June 2026 consumer cutover. |
| **Pi** | See [pi.dev](https://pi.dev) | No permission prompts and no sandbox by design. Read [Agent CLIs](Agent-CLIs) before using it on a repo you care about. |
+| **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. |
+| **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). |
+| **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. |
Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or
stores your CLI credentials.
@@ -161,6 +182,7 @@ Full detail, including logs and the self-updater, is in
| Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
+| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It restarts
the process that is running it, so the actual work happens in a detached script and the
diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md
index 8ba04e8a..68886a5a 100644
--- a/docs/wiki/Keeping-Agents-Running.md
+++ b/docs/wiki/Keeping-Agents-Running.md
@@ -27,9 +27,12 @@ keystroke echo. Idle now lands a few seconds after a turn genuinely ends.
There are several layers stacked on that: a completion message from the CLI, an AI check,
output silence, and token stability.
-**For every other CLI**, there are no hooks to lean on, so detection is output
-stabilization: the session is idle when output stops changing. Coarser, and it is why the
-features further down this page are Claude-only.
+**For the other CLIs** it depends on what the CLI tells Codeman. Codex declares its own
+prompt glyph and working line, so it gets the same screen check Claude does (before 1.26.1
+every Codex session reported idle for its whole life). DeepSeek Harness reports idle,
+working and blocked to Codeman itself, which is as precise as hooks. Everything else is
+output stabilization: the session is idle when output stops changing. Coarser, and it is
+why the features further down this page are Claude-only.
## The Respawn Controller
@@ -101,13 +104,18 @@ subscription plan.
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
off on phones.
-It works by installing a status line exporter into Claude Code, which posts Claude's own
-rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
-a status line Codeman installed, never one you wrote yourself, and it prints your footer
-through so the in-terminal status line still works.
+It works through a status line exporter that Codeman hands to `claude` as an ephemeral
+setting when it spawns the session, never written to disk, which posts Claude's own rate
+limit data back to Codeman. Your own status line (project-local, project, then
+`~/.claude/settings.json`) is wrapped and printed through, and a `claude` you run by hand
+outside Codeman sees nothing of it. Workspaces an older Codeman wrote the exporter into are
+cleaned up the first time a session starts there. Codex limits come from a read-only poll of
+its own app-server. Known limit: sessions inside a Docker case do not feed the chip yet.
The chip and the exporter are the same setting. Turning the chip on without the exporter
-would leave it showing a dash forever, so resolve it in one place: **App Settings**.
+would leave it showing a dash forever, so resolve it in one place: **App Settings**. A
+device writes the switch only when it flips the chip, so a phone (chip off by default)
+saving its font size cannot switch collection off for your desktop.
## Circuit breakers
diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md
index e0c15b3c..bfa32d5d 100644
--- a/docs/wiki/Keyboard-Shortcuts.md
+++ b/docs/wiki/Keyboard-Shortcuts.md
@@ -30,6 +30,9 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| `Ctrl+Shift+R` | Restore terminal size. |
| `Ctrl` `+` / `Ctrl` `-` | Font size. |
| `Shift+Wheel` | Scroll the local buffer, even where the wheel is forwarded to the CLI. |
+| `Shift+drag` | Start a selection in a pane whose mouse events go to the CLI. |
+| Right-click | Copy the selection. With nothing selected the native menu is left alone. |
+| `Ctrl+Z` | Swallowed in agent sessions so a running CLI cannot be suspended. Normal job control in a shell. |
## Everything else
diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md
index 9489c55e..1751db5c 100644
--- a/docs/wiki/Mobile-Guide.md
+++ b/docs/wiki/Mobile-Guide.md
@@ -30,8 +30,12 @@ require a secure context.
| Toolbar | Bottom: Run, Stop, **Enter**, case picker, voice, settings. |
| Keyboard bar | Above the on-screen keyboard when it is open. |
-Layout respects notch and home-indicator safe areas, touch targets are 44px, and the case
-picker is a bottom sheet rather than a dropdown.
+The phone layout applies up to 599px of viewport width, so the Plus and Pro Max iPhones,
+the Pixel Pro and a folded Z Fold get it too; wider devices get the tablet layout. Layout
+respects notch and home-indicator safe areas, touch targets are 44px, and the case picker is
+a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs stay clear of
+the hinge, and opening or closing the device is treated as the device changing shape, never
+as the keyboard appearing.
**Swipe left and right** on the terminal to switch sessions.
@@ -58,7 +62,9 @@ A row of keys above the virtual keyboard, and what it contains depends on the se
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
-double press, so you cannot fire `/clear` with a stray thumb.
+double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
+shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
+message and walking the prompt stack.
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md
index ed7de7f5..c096cd50 100644
--- a/docs/wiki/Notifications-And-Approvals.md
+++ b/docs/wiki/Notifications-And-Approvals.md
@@ -33,8 +33,8 @@ reloading the dashboard while a permission dialog is blocking a session does not
with a normal-looking tab.
For Claude sessions, these come from Claude Code's hooks and are precise about *why* the
-session stopped. For other CLIs there are no hooks, so you get the coarser output-based
-signal.
+session stopped; DeepSeek Harness sessions report the same states themselves. For the other
+CLIs there are no hooks, so you get the coarser output-based signal.
## Window title and OS notifications
@@ -62,7 +62,8 @@ Once subscribed, a blocking prompt reaches your phone even from a locked screen.
## The Approvals Inbox
-**Opt-in, off by default. Claude sessions only.**
+**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
+front door reports its prompts to Codeman.**
One queue of every prompt currently waiting on a human, across all your sessions, answerable
in place. When you have eight workers running, this is the difference between checking eight
@@ -136,7 +137,8 @@ from the lock screen.
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
- **iOS needs the home screen install.** A Safari tab will never receive push.
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
-- **Approvals are Claude-only.** They are built on hook events the other CLIs do not emit.
+- **Approvals need real signals.** They are built on hook events, which Claude emits and
+ DeepSeek Harness reports itself; the other CLIs do neither.
- **A stale menu answer is refused, not sent.** If you answer a card for a dialog that has
since gone away, Codeman declines rather than typing a digit into the composer.
diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md
index 91e1fc04..2256b545 100644
--- a/docs/wiki/Quick-Start.md
+++ b/docs/wiki/Quick-Start.md
@@ -67,6 +67,9 @@ one:
| **Gemini** | Enterprise only since Google's consumer cutover. |
| **Antigravity** | Google's successor to the consumer Gemini CLI. |
| **Pi** | No permission prompts and no sandbox by design. |
+| **Grok Build** | xAI's CLI. |
+| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
+| **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. |
| **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. |
The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent
diff --git a/docs/wiki/Remote-SSH-Sessions.md b/docs/wiki/Remote-SSH-Sessions.md
index 77b8f67a..d6e63b61 100644
--- a/docs/wiki/Remote-SSH-Sessions.md
+++ b/docs/wiki/Remote-SSH-Sessions.md
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
remote host.
-Like Docker, this is a **location overlay** on a case, not a run mode. All seven run modes
+Like Docker, this is a **location overlay** on a case, not a run mode. All ten run modes
work remotely. See [Core Concepts](Core-Concepts).
## Why bother
@@ -52,7 +52,10 @@ A watcher with bounded backoff notices a dead SSH pane and quietly reattaches to
running remote session. On by default; the kill switch is in
**App Settings → Agents & CLIs → Remote auto-reconnect**.
-Intentional kills are never revived. Closing a session means closing it.
+Intentional kills are never revived. Closing a session means closing it. Neither is a clean
+exit inside the pane (Ctrl-D, `exit`, Ctrl-C at the CLI's prompt): that tears the remote
+tmux session down, and the watcher revives a session only when that durable session is
+verifiably still alive. Only a transport drop is reconnected.
## Discover and attach
@@ -70,6 +73,14 @@ Attaching to someone else's session and closing your tab must not end their run,
not. Several clients can attach the same remote session at different window sizes without
clamping each other, and discovery shows a shared badge with the client count.
+## Files
+
+Previews, downloads and text reads in a remote case go over the same ssh connection the
+session uses, so a clicked path opens the file on the machine the agent is on, `Range`
+seeking included. Nothing is copied to the Codeman host. Editing, Office previews,
+thumbnails, the file tree and the tail viewer are not available remotely and answer a clear
+400 rather than a misleading 404. Details in [Working With Files](Working-With-Files).
+
## Security
Every SSH command line in Codeman flows through one builder that shell-escapes every
diff --git a/docs/wiki/Running-As-A-Service.md b/docs/wiki/Running-As-A-Service.md
index 17d7c49f..616ac0ea 100644
--- a/docs/wiki/Running-As-A-Service.md
+++ b/docs/wiki/Running-As-A-Service.md
@@ -139,6 +139,7 @@ log stream --predicate 'process == "node"' # macOS, noisy
| Installer | Re-run the one-liner, or **App Settings → System → Updates**. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
+| Docker Compose | Re-run `Start-Codeman.sh`, or the in-app updater, which restarts the container in place. |
### The in-app updater
@@ -170,6 +171,18 @@ service without colliding with the main one. `CODEMAN_DATA_DIR` and `CODEMAN_TMU
exist for the rare case where they need to differ, but setting only one of them recreates
exactly the problem you were avoiding.
+## Running Codeman itself in Docker
+
+The Compose deployment in `docker/` runs the server in a container and spawns Docker cases
+as sibling containers through the mounted host socket. Start it with
+`bash docker/Start-Codeman.sh` rather than a bare `docker compose up`: the script pre-creates
+the bind-mounted directories with the right owner, honours a `docker-compose.override.yml`,
+and refreshes the build volumes when the checkout moved under them. The in-app updater
+applies code only and restarts by letting the container exit, so it refuses a release that
+changes the Dockerfile, the compose file, or adds a new `.env` key, until you re-run the
+script. Guide:
+[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
+
## The tunnel as a service
```bash
diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md
index 2e16511e..8dbaaa38 100644
--- a/docs/wiki/Security.md
+++ b/docs/wiki/Security.md
@@ -67,6 +67,7 @@ be wrong for at least one of them:
| **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. |
| **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. |
| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. |
+| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. |
Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS
credentials), and SVG and HTML are served as downloads with `nosniff` so they cannot execute
diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md
index 6e8e31ed..3255f56f 100644
--- a/docs/wiki/Settings-Reference.md
+++ b/docs/wiki/Settings-Reference.md
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
+| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
@@ -72,10 +73,13 @@ every session or only the active tab.
| 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) or a collapsible left sidebar. See [The Dashboard](The-Dashboard#session-list-layout). |
+| 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). |
+| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
+| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. 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. |
### Models
@@ -155,6 +159,9 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. |
| `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. |
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
+| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
+| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
+| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
## Gotchas
diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md
index d5130869..a0e86e5c 100644
--- a/docs/wiki/The-Dashboard.md
+++ b/docs/wiki/The-Dashboard.md
@@ -22,12 +22,14 @@ page says so and names the setting.
The session list lives in the header as a horizontal strip by default. With a lot of
sessions open that strip stops being scannable, so **App Settings → Appearance → Tabs →
-Session List Layout** can move it into a vertical sidebar on the left instead.
+Session List Layout** can move it into a vertical sidebar on the left instead, and
+**Tab Orientation** can turn the strip itself into a vertical rail.
| Layout | Behaviour |
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, 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. |
+| **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. 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
@@ -66,6 +68,18 @@ reloading while a permission prompt is blocking does not lose the red tab.
Tabs can also be dragged to reorder.
+### Automatic session names
+
+Off by default. Turn on **Auto-name Sessions** (App Settings → Appearance → Tabs; synced
+across devices) and a tab that still carries its generated name, such as `w3-myapp`, takes a
+title from the first real prompt you submit, keeping the prefix: `w3-myapp: fix the login
+redirect`. The strip shows the title and keeps the prefix in the tooltip, and the next
+session in that case still counts up to `w4-myapp`. It happens once per session, only for
+prompts you type or send through the input API (never a Ralph, respawn, cron or approval
+answer), and never for shells. Slash commands such as `/clear` do not become titles; the
+next prompt gets its turn. A name you set yourself, before or after, is never touched. The
+title is derived locally from the prompt's first sentence; no text leaves the machine.
+
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
@@ -142,6 +156,10 @@ Worth knowing:
always local scrollback. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
+- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
+ whose mouse events are forwarded to the CLI, and right-click copies the selection (with
+ nothing selected the native menu is left alone). **Auto Copy Selection** in App Settings
+ copies the moment you release.
- **Zero-lag input.** On touch devices, keystrokes paint locally before the round trip. See
[Input And Voice](Input-And-Voice).
- **Renderer.** WebGL by default, with a watchdog that falls back to DOM rendering if the
@@ -155,8 +173,9 @@ which lists past sessions including Claude conversations started outside Codeman
Two extras depending on the device:
-- **Desktop, wide windows**: your open tabs appear as a rail docked to the left edge, in tab
- order, with created and last-active stamps. It needs at least 1180px of width; below that
+- **Desktop, wide windows**: your open tabs appear as a rail docked to the left edge, in
+ overview order (blocked on you first, then longest running, then most recently quiet),
+ with created and state-duration stamps. It needs at least 1180px of width; below that
it is hidden so it cannot overlap the search panel.
- **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then
current sessions, then past ones. On by default.
@@ -192,7 +211,9 @@ so it is fast and cannot be turned into a traversal.
## Appearance
**App Settings → Appearance** carries the theme skins, including light ones. The choice is
-applied before the first paint, so there is no flash of the wrong theme on load.
+applied before the first paint, so there is no flash of the wrong theme on load. Terminal
+font family and weight are per device too: a normal and a bold weight, each from 100 to
+900, and the bundled JetBrains Mono renders every step.
The same section has the entrance animations for tabs, terminals, agent windows, and
lineage lines. All of them default to the legacy no-animation behaviour, so an untouched
diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md
index 75b3c768..7eb279b3 100644
--- a/docs/wiki/Troubleshooting.md
+++ b/docs/wiki/Troubleshooting.md
@@ -138,6 +138,12 @@ That is the PTY-exit circuit breaker. Repeated rapid PTY exits trip it, and it b
automatic restarts so a broken configuration does not spin forever. Reset it explicitly from
the session's controls. Reattaching does not clear it, deliberately.
+### Typed prompts are silently ignored after restoring a tab
+
+Update. A browser whose input sequence counter fell behind the server's (a restored tab,
+cleared site data) used to have every prompt deduplicated away. Since 1.29.0 the duplicate
+acknowledgement carries the watermark and the client re-sends.
+
### Sessions I did not create appeared, or my session resized itself
Two Codeman servers are running against the same data directory and tmux socket. The second
@@ -167,6 +173,16 @@ Things to try:
Codex ignores the mouse reports that forwarding would send, so Codeman does not forward
there. Scrolling is local, and `Shift+Wheel` behaves the same way.
+### Selected text is invisible on a light skin
+
+Update. Every skin named its selection colour under a key xterm renamed in v5, so the four
+light skins painted white at 30% over near-white. Fixed in 1.29.0.
+
+### `Ctrl+Z` suspended my agent
+
+Update. Since 1.28.0 `Ctrl+Z` is swallowed in agent sessions, so a running CLI cannot be
+stopped by job control. Shell sessions keep it.
+
### `Ctrl+C` copies when I wanted to interrupt
With a selection, `Ctrl+C` copies. With no selection, it interrupts. Clear the selection
@@ -252,11 +268,25 @@ node scripts/build-agent-image.mjs --no-cache
A plain rebuild reuses the cached `npm install -g` layer and keeps the CLIs frozen at their
original versions while reporting success.
+### Every file in a remote case says "File not found"
+
+Update. Before 1.29.0 the file routes resolved every path on the Codeman host, so in a
+remote case every click failed while the file plainly existed on the other machine. Reads
+now go over ssh; see [Working With Files](Working-With-Files). Editing and Office previews
+stay unavailable remotely and say so with a 400.
+
+### Compose: the server crash-loops with `EACCES` on first start
+
+Start the stack with `bash docker/Start-Codeman.sh` rather than a plain `docker compose up`,
+and update: since 1.29.0 the entrypoint corrects a root-owned bind mount before dropping
+privileges. See [Running As A Service](Running-As-A-Service).
+
### A remote SSH session dropped and did not come back
A bounded-backoff watcher reattaches dropped sessions, and it is on by default. Intentional
-kills are never revived. Check the host is reachable and that the remote tmux server is
-still running.
+kills are never revived, and neither is a clean exit inside the pane (Ctrl-D, `exit`): only
+a transport drop is reconnected. Check the host is reachable and that the remote tmux server
+is still running.
## Gathering diagnostics
diff --git a/docs/wiki/Web-Tabs.md b/docs/wiki/Web-Tabs.md
index 76744dad..b22e5010 100644
--- a/docs/wiki/Web-Tabs.md
+++ b/docs/wiki/Web-Tabs.md
@@ -24,6 +24,23 @@ Switching tabs does not reload a dashboard. Frames stay alive in the background,
took a while to authenticate is still there when you come back. Past six live frames, the
least recently viewed is dropped to bound memory.
+## Single-page apps, reloads and links
+
+A history-routed dashboard (React Router, Vue Router, a Vite dev server) sees the path it
+would see on its own origin, not the proxy prefix, so it renders its real route instead of
+its own "page not found". A navigation the page starts itself afterwards, a dev server's
+full reload or a root-absolute `location.href`, would land outside the proxy with no
+capability; Codeman recognises it, answers with a small recovery page, and remounts the
+frame at the path that was lost, bounded to five recoveries a minute per frame. A reload on
+the dashboard's landing page is recovered the same way.
+
+A `localhost` or `127.0.0.1` link in agent output opens as a web tab automatically, reusing
+a saved dashboard for the same server or saving one under its `host:port`. On a phone that
+address only exists on the Codeman box, so the link would otherwise be a guaranteed
+connection error. LAN and tailnet addresses still open directly. `*.localhost` names are
+deliberately not auto-routed: they are DNS names rather than address literals, and the link
+came from agent output. Add such a dashboard by hand instead.
+
## Why dashboards are proxied
A plain cross-origin iframe fails three ways at once in the setup Codeman actually ships in:
@@ -89,6 +106,12 @@ The proxy authenticates on an in-memory capability embedded in the path, which i
exempt from the cookie and Origin checks that every API route enforces. That exemption is
fenced to safe methods and non-API paths, and there is a test pinning it in place.
+Saved URLs are refused when they point at a link-local or cloud-metadata address, at save
+time and again against the address the name resolves to at connect time; loopback and
+private ranges stay allowed, because a `localhost` Grafana is the feature. Capabilities are
+revoked on logout, and proxied responses carry a same-origin referrer policy so a dashboard
+cannot hand the capability-bearing URL to a third party.
+
Two failure modes that only appear inside a sandboxed frame, and that curl can never
reproduce, are handled: runtime-built root-absolute URLs escaping the injected base, and
same-host requests being CORS-checked with a null origin. Both present as the dashboard's own
diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md
index b579c4cd..d9ebaf48 100644
--- a/docs/wiki/Working-With-Files.md
+++ b/docs/wiki/Working-With-Files.md
@@ -110,6 +110,22 @@ it is written. Outside the workspace they open in the preview instead: the tail
Nothing is registered until you click. Opening a file this way does not add an attachment card.
+## Remote (SSH) cases
+
+In a remote case the workspace lives on the other machine, and so do the files. Previews,
+downloads, text reads and the clicked-path route all go over the same ssh connection the
+session uses: one `realpath` plus `stat` probe for the file and the workspace root, then a
+streamed `cat` (or a slice of it, so video seeking works). Symlinks are resolved on the host
+that can resolve them, the size cap applies to the remote size before a byte is requested,
+and an unreachable host answers 502 rather than pretending the file is missing. Nothing is
+ever copied onto the Codeman host, and a same-named local file is never served under a
+remote name.
+
+Not available over ssh, and said so with a 400 instead of a misleading 404: editing in
+place, Office previews and generated thumbnails (both need the bytes on the server's disk),
+the file tree and path picker, and the tail viewer. Docker cases are unaffected, because
+their workspace is bind-mounted at the same path.
+
## The path picker
For choosing a path rather than typing one. It appears in two places:
diff --git a/package-lock.json b/package-lock.json
index e7293301..22edc788 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
- "version": "1.29.0",
+ "version": "1.29.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
- "version": "1.29.0",
+ "version": "1.29.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
diff --git a/package.json b/package.json
index 791bc3cc..287879ba 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
- "version": "1.29.0",
+ "version": "1.29.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json
index 65fd65d6..8d16d73b 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.29.0",
+ "version": "1.29.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/src/session-auto-name.ts b/src/session-auto-name.ts
new file mode 100644
index 00000000..fa3f1448
--- /dev/null
+++ b/src/session-auto-name.ts
@@ -0,0 +1,347 @@
+/**
+ * @fileoverview Automatic session names from the first prompt.
+ *
+ * A new tab is born as `w3-myapp`, which says where it runs and nothing about
+ * what it is doing. Once the user submits a real prompt the tab can carry a
+ * title derived from it (`w3-myapp: fix the login redirect`), and this module
+ * holds the three pure pieces of that: a tracker that reconstructs the composer
+ * text from the keystrokes Codeman forwards, the title heuristic, and the
+ * prefix-preserving composition.
+ *
+ * Deliberately no LLM: the prompt already passes through the input boundary,
+ * so a local title is private, deterministic and identical for every CLI.
+ *
+ * ⚠️ The tracker sits on the raw keystroke stream, which carries far more than
+ * the prompt: cursor keys, mouse reports Codeman forwards to the CLI, bracketed
+ * pastes, Alt chords, the bare Esc that interrupts a turn. Every one of those
+ * once named a tab something wrong (a lone Esc ate the next prompt's first
+ * character; a wheel tick mid-word dropped the first half of the prompt), so
+ * the rules below are explicit per key. The model is a best-effort transcript:
+ * keys whose effect on the composer is knowable are mirrored, keys that leave
+ * the text alone are ignored, and keys that replace it with something the
+ * tracker cannot see (history recall) TAINT the draft so that Enter submits
+ * nothing rather than a fragment. A prompt that yields no title leaves the
+ * session eligible for the next one.
+ *
+ * Only user-originated input is fed here; the Session decides that. Ralph
+ * kick-starts, respawn `/clear`s, cron launches and approval answers all go
+ * through the same write paths and must never become a tab title.
+ *
+ * @module session-auto-name
+ */
+
+import { MAX_SESSION_NAME_LENGTH } from './config/terminal-limits.js';
+
+/**
+ * Longest composer draft kept, in code points. The title is cut from the HEAD
+ * of the prompt, so once the cap is reached further text is counted rather
+ * than kept (backspaces consume that count first). Keeping the tail instead
+ * would turn a long paste into a title made of its last line.
+ */
+const MAX_PROMPT_BUFFER_CODE_POINTS = 8_192;
+
+/** Longest escape sequence collected before the tracker gives up on it. */
+const MAX_ESCAPE_SEQUENCE_LENGTH = 64;
+
+/** Longest title, in code points, before it is cut with an ellipsis. */
+const MAX_AUTO_NAME_CODE_POINTS = 72;
+
+/**
+ * A sentence boundary is only honoured this far into the prompt, or "e.g. fix
+ * this now" becomes "e.g." and "Ok. Fix the bug" becomes "Ok". Short enough
+ * that a CJK sentence (a dozen code points is a full request) still cuts.
+ */
+const MIN_SENTENCE_CODE_POINTS = 8;
+
+/** A CSI sequence ends at its first byte in this range. */
+const CSI_FINAL_BYTE = /[\x40-\x7e]/;
+/** CSI parameter and intermediate bytes; anything else mid-sequence is malformed. */
+const CSI_BODY_BYTE = /[\x20-\x3f]/;
+
+/**
+ * `/clear`, `/model opus`, `/ralph-loop:ralph-loop`: a slash followed by a
+ * command word and then whitespace or the end. A path (`/home/me/notes.txt
+ * what is this`) has a second slash where the whitespace should be and so is a
+ * prompt.
+ */
+const SLASH_COMMAND_PATTERN = /^\/[a-z][a-z0-9_:-]*(?:\s|$)/i;
+
+// eslint-disable-next-line no-control-regex
+const CSI_SEQUENCE_PATTERN = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHAR_PATTERN = /[\x00-\x1f\x7f]/g;
+const SENTENCE_TERMINATORS = new Set(['.', '!', '?', '。', '!', '?']);
+
+/**
+ * Reconstructs the composer draft from forwarded keystrokes and reports each
+ * submitted prompt. Input arrives in arbitrary chunks (one keystroke, a paste,
+ * an agent's whole prompt plus Enter), so all state lives across calls.
+ */
+export class SubmittedPromptTracker {
+ private buffer = '';
+ private bufferCodePoints = 0;
+ /** Code points typed past the cap; backspaces eat these before real text. */
+ private overflow = 0;
+ /** Escape sequence in progress; a lone ESC means "just saw ESC". */
+ private sequence = '';
+ private inPaste = false;
+ /** The composer holds text the tracker never saw (history recall); Enter submits nothing. */
+ private tainted = false;
+
+ feed(data: string): string[] {
+ const submitted: string[] = [];
+ for (const ch of data) {
+ if (this.sequence) {
+ this.continueSequence(ch);
+ continue;
+ }
+ if (ch === '\x1b') {
+ this.sequence = ch;
+ continue;
+ }
+ this.handleKey(ch, submitted);
+ }
+ // A chunk that ENDS in a lone ESC is the Esc key, not the start of a
+ // sequence: xterm hands each key's whole sequence to one write, and the
+ // programmatic senders (an approval deny sends exactly `\x1b`) send it
+ // alone. Leaving it pending would make the next prompt's first character
+ // look like an Alt chord and swallow it.
+ if (this.sequence === '\x1b') this.sequence = '';
+ return submitted;
+ }
+
+ private continueSequence(ch: string): void {
+ if (this.sequence === '\x1b') {
+ if (ch === '[' || ch === 'O' || ch === ']' || ch === 'P') {
+ this.sequence += ch;
+ return;
+ }
+ this.sequence = ch === '\x1b' ? ch : '';
+ // Alt+Enter inserts a newline in the composer; every other Alt chord
+ // (word movement, Alt+B/F) leaves the text alone.
+ if (ch === '\r' || ch === '\n') this.appendSeparator();
+ return;
+ }
+
+ this.sequence += ch;
+ if (this.sequence.length > MAX_ESCAPE_SEQUENCE_LENGTH) {
+ // Not a sequence any terminal sends; what follows is unknowable, so the
+ // draft is tainted rather than titled after the tail of the garbage.
+ this.sequence = '';
+ this.tainted = true;
+ return;
+ }
+
+ const kind = this.sequence[1];
+ if (kind === '[') {
+ if (CSI_FINAL_BYTE.test(ch)) {
+ const sequence = this.sequence;
+ this.sequence = '';
+ this.handleCsi(sequence);
+ } else if (!CSI_BODY_BYTE.test(ch)) {
+ // Malformed (an ESC [ followed by text): drop the sequence and let the
+ // character count as typed rather than swallowing up to 64 of them.
+ this.sequence = '';
+ this.handleKeyOrEscape(ch);
+ }
+ return;
+ }
+ if (kind === 'O') {
+ // SS3 carries exactly one byte (application-mode cursor keys).
+ this.sequence = '';
+ if (ch === 'A' || ch === 'B') this.tainted = true;
+ return;
+ }
+ // OSC / DCS run to BEL or ST (ESC \).
+ if (ch === '\x07' || this.sequence.endsWith('\x1b\\')) this.sequence = '';
+ }
+
+ private handleKeyOrEscape(ch: string): void {
+ if (ch === '\x1b') {
+ this.sequence = ch;
+ return;
+ }
+ // Only reached mid-chunk from a malformed sequence, where no submission can
+ // be reported; a stray Enter there resets the draft like any other Enter.
+ this.handleKey(ch, []);
+ }
+
+ private handleCsi(sequence: string): void {
+ if (sequence === '\x1b[200~') {
+ this.inPaste = true;
+ return;
+ }
+ if (sequence === '\x1b[201~') {
+ this.inPaste = false;
+ return;
+ }
+ const final = sequence[sequence.length - 1];
+ // Up/Down (with or without modifiers) recall history: the composer now
+ // holds a line this tracker never saw. Everything else leaves the text as
+ // it is: Left/Right/Home/End, Delete (`3~`), Shift+Tab (`Z`), SGR mouse
+ // reports (`<…M`/`m`, forwarded on every wheel tick), focus reports.
+ if (final === 'A' || final === 'B') this.tainted = true;
+ }
+
+ private handleKey(ch: string, submitted: string[]): void {
+ const codePoint = ch.codePointAt(0) ?? 0;
+ if (this.inPaste) {
+ // Pasted newlines are newlines IN the composer, never Enter; they and
+ // the other controls (tabs) become a single separator.
+ if (codePoint < 0x20 || codePoint === 0x7f) this.appendSeparator();
+ else this.append(ch);
+ return;
+ }
+ switch (ch) {
+ case '\r': {
+ const prompt = this.tainted ? '' : this.buffer.trim();
+ if (prompt) submitted.push(prompt);
+ this.reset();
+ return;
+ }
+ case '\n':
+ // Ctrl+J, and the line feed the send-key route injects for Shift+Enter:
+ // a newline inside the composer, so the lines join with a separator.
+ this.appendSeparator();
+ return;
+ case '\x7f':
+ case '\x08':
+ this.backspace();
+ return;
+ case '\x17': // Ctrl+W: word rubout
+ this.killWord();
+ return;
+ case '\x15': // Ctrl+U: line discard
+ case '\x03': // Ctrl+C: clears the composer (or, empty, arms an exit)
+ this.reset();
+ return;
+ case '\x10': // Ctrl+P
+ case '\x0e': // Ctrl+N
+ case '\x12': // Ctrl+R: history search
+ case '\x1f': // Ctrl+_: undo
+ this.tainted = true;
+ return;
+ default:
+ // Tab (the @-mention completer, which only ever extends the token),
+ // cursor chords (Ctrl+A/E/B/F) and the rest of C0 leave the text alone.
+ if (codePoint < 0x20 || codePoint === 0x7f) return;
+ this.append(ch);
+ }
+ }
+
+ /** One space between lines, never a run of them, and none at the start. */
+ private appendSeparator(): void {
+ if (this.overflow > 0) return;
+ if (!this.buffer || /\s$/.test(this.buffer)) return;
+ this.append(' ');
+ }
+
+ private append(ch: string): void {
+ if (this.bufferCodePoints >= MAX_PROMPT_BUFFER_CODE_POINTS) {
+ this.overflow += 1;
+ return;
+ }
+ this.buffer += ch;
+ this.bufferCodePoints += 1;
+ }
+
+ private backspace(): void {
+ if (this.overflow > 0) {
+ this.overflow -= 1;
+ return;
+ }
+ if (!this.buffer) return;
+ const last = this.buffer.charCodeAt(this.buffer.length - 1);
+ const units = last >= 0xdc00 && last <= 0xdfff && this.buffer.length >= 2 ? 2 : 1;
+ this.buffer = this.buffer.slice(0, -units);
+ this.bufferCodePoints -= 1;
+ }
+
+ private killWord(): void {
+ this.overflow = 0;
+ this.buffer = this.buffer.replace(/\S+\s*$/u, '');
+ this.bufferCodePoints = Array.from(this.buffer).length;
+ }
+
+ private reset(): void {
+ this.buffer = '';
+ this.bufferCodePoints = 0;
+ this.overflow = 0;
+ this.tainted = false;
+ }
+}
+
+/**
+ * Turns a submitted prompt into a title, or null when the prompt is not a task:
+ * empty, a slash command (`/clear`, `/model`), or a `!` shell escape.
+ */
+export function deriveAutoSessionName(prompt: string): string | null {
+ const text = prompt.replace(CSI_SEQUENCE_PATTERN, '').replace(CONTROL_CHAR_PATTERN, ' ').replace(/\s+/g, ' ').trim();
+ if (!text || text.startsWith('!') || SLASH_COMMAND_PATTERN.test(text)) return null;
+ return truncateCodePoints(firstSentence(text), MAX_AUTO_NAME_CODE_POINTS);
+}
+
+/**
+ * The first sentence, provided it is long enough to be one; a trailing full
+ * stop is dropped because a tab title is not a sentence.
+ */
+function firstSentence(text: string): string {
+ const codePoints = Array.from(text);
+ for (let i = MIN_SENTENCE_CODE_POINTS - 1; i < codePoints.length; i++) {
+ if (!SENTENCE_TERMINATORS.has(codePoints[i])) continue;
+ const next = codePoints[i + 1];
+ if (next !== undefined && !/\s/.test(next)) continue;
+ return codePoints
+ .slice(0, i + 1)
+ .join('')
+ .replace(/[.。]+$/, '');
+ }
+ return text.replace(/[.。]+$/, '');
+}
+
+/** Cuts to `max` code points with an ellipsis, on a word boundary when one is near the end. */
+function truncateCodePoints(text: string, max: number): string {
+ const codePoints = Array.from(text);
+ if (codePoints.length <= max) return text;
+ let cut = codePoints.slice(0, max - 1).join('');
+ const lastSpace = cut.lastIndexOf(' ');
+ if (lastSpace >= Math.floor(cut.length / 2)) cut = cut.slice(0, lastSpace);
+ return `${cut.trimEnd()}…`;
+}
+
+/**
+ * The name a placeholder becomes: `: `, so the tab keeps its
+ * case identity and its `w` counter (the tab strip already renders that
+ * form as the title alone, prefix in the tooltip, and the next-session counter
+ * still matches it). A session with no name at all just takes the title. The
+ * result honours `maxLength` in UTF-16 units, the unit the rename route caps.
+ */
+export function composeAutoSessionName(
+ currentName: string,
+ title: string,
+ maxLength = MAX_SESSION_NAME_LENGTH
+): string {
+ const prefix = currentName.trim();
+ if (!prefix) return fitTitle(title, maxLength);
+ const room = maxLength - prefix.length - 2;
+ if (room <= 0) return prefix;
+ return `${prefix}: ${fitTitle(title, room)}`;
+}
+
+/** Fits a title into `maxUnits` UTF-16 units, ellipsis included. */
+function fitTitle(title: string, maxUnits: number): string {
+ if (title.length <= maxUnits) return title;
+ let units = 0;
+ let keep = 0;
+ for (const codePoint of Array.from(title)) {
+ if (units + codePoint.length > maxUnits - 1) break;
+ units += codePoint.length;
+ keep += 1;
+ }
+ return truncateCodePoints(title, keep + 1);
+}
+
+/** Codeman's own `w-` / `s-` placeholders, the only names auto-naming replaces. */
+export function isGeneratedSessionName(name: string): boolean {
+ return /^[ws]\d+-[a-zA-Z0-9_-]+$/.test(name);
+}
diff --git a/src/session.ts b/src/session.ts
index 5486b722..21d5ac89 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -23,7 +23,7 @@
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
* task-tracker (background tasks), mux-interface (tmux abstraction)
* @consumedby session-manager, web/server, respawn-controller
- * @emits session:terminal, session:idle, session:working, session:completion, session:exit
+ * @emits session:terminal, session:idle, session:working, session:completion, session:promptSubmitted, session:exit
*
* @module session
*/
@@ -58,6 +58,8 @@ import {
type OmpConfig,
type SessionRemote,
type SessionDocker,
+ type SessionNameSource,
+ type SessionWriteOptions,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { probeDockerCliVersion } from './docker-hosts.js';
@@ -121,6 +123,7 @@ import { SessionAutoOps } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js';
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
+import { isGeneratedSessionName, SubmittedPromptTracker } from './session-auto-name.js';
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
import {
sanitizeAttachmentHistory,
@@ -423,6 +426,15 @@ export class Session extends EventEmitter {
private _taskCache = new SessionTaskCache();
private _name: string;
+ private _nameSource: SessionNameSource;
+ /**
+ * Reconstructs the composer draft from USER keystrokes so the first real
+ * prompt can name the tab. Fed only when a write says `fromUser`, and never
+ * for a CLI whose Enter runs a command rather than submitting a prompt
+ * (`startMode: 'shell'`), so a shell tab is not renamed after every `ls`.
+ */
+ private readonly _submittedPromptTracker = new SubmittedPromptTracker();
+ private readonly _acceptsPrompts: boolean;
private ptyProcess: pty.IPty | null = null;
private _pid: number | null = null;
private _status: SessionStatus = 'idle';
@@ -654,6 +666,12 @@ export class Session extends EventEmitter {
workingDir: string;
mode?: SessionMode;
name?: string;
+ /**
+ * Who owns the name (see `SessionNameSource`). Omitted, it is inferred
+ * from the name: Codeman's own `w-` placeholders (or no name)
+ * stay eligible for auto-naming, anything else counts as the user's.
+ */
+ nameSource?: SessionNameSource;
/** Terminal multiplexer instance (tmux) */
mux?: TerminalMultiplexer;
/** Whether to use multiplexer wrapping */
@@ -723,6 +741,9 @@ export class Session extends EventEmitter {
this.createdAt = config.createdAt || Date.now();
this.mode = config.mode || 'claude';
this._name = config.name || '';
+ this._nameSource =
+ config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'placeholder' : 'manual');
+ this._acceptsPrompts = getCli(this.mode)?.capabilities.startMode !== 'shell';
this._resumeSessionId = config.resumeSessionId;
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
// days-old tmux session, and seeding last-activity from it would report a
@@ -1370,8 +1391,31 @@ export class Session extends EventEmitter {
return this._name;
}
+ /** An explicit rename: the name is the user's from here on and auto-naming never touches it. */
set name(value: string) {
this._name = value;
+ this._nameSource = 'manual';
+ }
+
+ /**
+ * Names the tab after its first prompt. Only a placeholder is eligible, and
+ * the session stops being one whether or not the string changed: "first
+ * prompt" means the first, not "every prompt until a rename". Returns
+ * whether the name changed, so the caller knows whether to persist and
+ * broadcast.
+ */
+ applyAutoName(value: string): boolean {
+ if (this._nameSource !== 'placeholder') return false;
+ const name = value.trim();
+ if (!name) return false;
+ this._nameSource = 'auto';
+ if (this._name === name) return false;
+ this._name = name;
+ return true;
+ }
+
+ get nameSource(): SessionNameSource {
+ return this._nameSource;
}
setAutoClear(enabled: boolean, threshold?: number): void {
@@ -1515,6 +1559,7 @@ export class Session extends EventEmitter {
// attach repaint, so the home screens' quiet ordering survives a restart.
lastActivityAt: this._wireActivityAt,
name: this._name,
+ nameSource: this._nameSource,
mode: this.mode,
autoClearEnabled: this._autoOps.autoClearEnabled,
autoClearThreshold: this._autoOps.autoClearThreshold,
@@ -3513,10 +3558,11 @@ export class Session extends EventEmitter {
* discards the data, but it used to do so with no signal at all — which is how
* input could disappear while the caller believed it had been delivered.
*/
- write(data: string): boolean {
- this._trackSubmit(data);
+ write(data: string, options: SessionWriteOptions = {}): boolean {
+ const submittedPrompt = this._trackSubmit(data, options);
if (!this.ptyProcess) return false;
this.ptyProcess.write(data);
+ this._emitSubmittedPrompt(submittedPrompt);
return true;
}
@@ -3533,10 +3579,35 @@ export class Session extends EventEmitter {
return this._lastSubmitAt;
}
- private _trackSubmit(data: string): void {
+ /**
+ * Stamps the pane's last Enter for EVERY write, and feeds the auto-name
+ * tracker only for user-originated input on a prompt-taking CLI. Ralph
+ * kick-starts, respawn `/clear`s, cron launches, approval answers and the
+ * trust-dialog keys all arrive without `fromUser` and so can never name a tab.
+ */
+ private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
+ const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
}
+ return submitted;
+ }
+
+ /**
+ * Feeds user input that reaches the pane AROUND the write paths: the
+ * send-key route injects Shift+Enter's line feed through `tmux send-keys -H`
+ * directly, and without this the two lines of a prompt joined with no
+ * separator. Reports submissions like a write would (a line feed never is one).
+ */
+ trackUserInput(data: string): void {
+ if (!this._acceptsPrompts) return;
+ this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
+ }
+
+ private _emitSubmittedPrompt(prompts: string[]): void {
+ for (const prompt of prompts) {
+ this.emit('promptSubmitted', prompt);
+ }
}
/**
@@ -3633,14 +3704,17 @@ export class Session extends EventEmitter {
* session.writeViaMux('/init\r'); // Send /init command
* ```
*/
- async writeViaMux(data: string): Promise {
- this._trackSubmit(data);
+ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise {
+ const submittedPrompt = this._trackSubmit(data, options);
if (this._mux && this._muxSession) {
- return this._mux.sendInput(this.id, data);
+ const sent = await this._mux.sendInput(this.id, data);
+ if (sent) this._emitSubmittedPrompt(submittedPrompt);
+ return sent;
}
// Fallback to PTY write
if (this.ptyProcess) {
this.ptyProcess.write(data);
+ this._emitSubmittedPrompt(submittedPrompt);
return true;
}
return false;
diff --git a/src/types/session.ts b/src/types/session.ts
index 1a82a0f3..c993666f 100644
--- a/src/types/session.ts
+++ b/src/types/session.ts
@@ -58,6 +58,24 @@ export type SessionMode =
| 'deepseek'
| 'omp';
+/**
+ * Who owns a session's name. `placeholder`: Codeman's own `w-` (or no
+ * name at all), still eligible for auto-naming. `auto`: titled after its first
+ * prompt (`w-: `), which happens once. `manual`: set by a
+ * person; auto-naming never touches it.
+ */
+export type SessionNameSource = 'placeholder' | 'auto' | 'manual';
+
+/** Options for `Session.write()` / `Session.writeViaMux()`. */
+export interface SessionWriteOptions {
+ /**
+ * The bytes were typed by a person, or sent by an agent on their behalf
+ * (browser keystrokes, `POST /api/sessions/:id/input`). Only such input can
+ * name a tab; Ralph, respawn, cron and approval writes leave this unset.
+ */
+ fromUser?: boolean;
+}
+
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
@@ -614,6 +632,8 @@ export interface SessionState {
lastActivityAt: number;
/** Session display name */
name?: string;
+ /** Who owns the name (see `SessionNameSource`); absent on states persisted before auto-naming existed. */
+ nameSource?: SessionNameSource;
/** Session mode */
mode?: SessionMode;
/** Auto-clear enabled */
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index 23605d2f..bee8fcab 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -252,6 +252,7 @@
'Ultracode Agents': 'Ultracode 智能体',
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
+ 'Auto-name Sessions': '自动命名会话',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 39e956f1..2ba80243 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2047,6 +2047,13 @@
+
+
+ Auto-name Sessions synced
+ Title a new tab after its first prompt, keeping the case prefix. Renamed tabs are never touched.
+
+
+
Overview Home Screen phone
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 92631b0f..79bee3c0 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
// header), so the row is hidden elsewhere rather than offering a toggle that
// changes nothing. Default ON — only an explicit false turns it off.
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
+ // Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
+ document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
+ autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index caad748b..03f92e5d 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -1595,6 +1595,9 @@ export function registerSessionRoutes(
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
+ // Every write here is `fromUser`: this route carries a person's prompt, or an
+ // agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
+ // approvals write through the session directly and never say so).
//
// Because the response has already been sent by then, a failure there is the
// one case the caller can never learn about — so the dedup bookkeeping is
@@ -1617,32 +1620,32 @@ export function registerSessionRoutes(
} else if (useMux && waitPromise) {
// The response is already staying open for the wait, so the tmux write can be
// awaited here. This is the ONE path where a writeViaMux failure is observable.
- const ok = await session.writeViaMux(inputStr).catch(() => false);
+ const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
if (ok) {
delivered = true;
} else {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
- delivered = session.write(inputStr);
+ delivered = session.write(inputStr, { fromUser: true });
if (!delivered) undoOnFailure();
}
} else if (useMux) {
// Fire-and-forget: don't block the HTTP response on a tmux child process.
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
session
- .writeViaMux(inputStr)
+ .writeViaMux(inputStr, { fromUser: true })
.then((ok) => {
if (ok) return;
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
- if (!session.write(inputStr)) undoOnFailure();
+ if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
})
.catch(() => {
- if (!session.write(inputStr)) undoOnFailure();
+ if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
});
} else {
// Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx.
- delivered = session.write(inputStr);
+ delivered = session.write(inputStr, { fromUser: true });
if (!delivered && tagged) {
session.forgetInputSeq(clientId as string, seq as number);
}
@@ -1891,6 +1894,9 @@ export function registerSessionRoutes(
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
}
+ // The bytes bypassed the session's write path, so tell the auto-name
+ // tracker about them or the two lines of a prompt join with no separator.
+ session.trackUserInput(hex.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''));
return {};
});
diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts
index ed60f5d3..ec7fe9e1 100644
--- a/src/web/routes/ws-routes.ts
+++ b/src/web/routes/ws-routes.ts
@@ -185,7 +185,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
- delivered = session.write(msg.d);
+ // Browser keystrokes are the user's own, so they may name the tab.
+ delivered = session.write(msg.d, { fromUser: true });
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index e7e8c28c..b5dabdd0 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -1230,6 +1230,13 @@ export const SettingsUpdateSchema = z
* already pending immediately.
*/
approvalsInboxEnabled: z.boolean().optional(),
+ /**
+ * Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
+ * prompt as a title (`w3-case: fix the login redirect`). Synced, default
+ * OFF: the prompt lands in mux-sessions.json, every session:updated
+ * broadcast and /api/search, which is the user's choice to make.
+ */
+ autoNameSessions: z.boolean().optional(),
/**
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
diff --git a/src/web/server.ts b/src/web/server.ts
index a863cb37..cf5927c0 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -1728,6 +1728,10 @@ export class WebServer extends EventEmitter {
getStore: () => this.store,
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
this.registerAttachment(id, filePath, source),
+ updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
+ // Opt-in: the first prompt lands in the tab name, mux-sessions.json, every
+ // session:updated broadcast and /api/search, so it is a choice, not a default.
+ isAutoNameEnabled: async () => (await this.readSettings()).autoNameSessions === true,
};
}
@@ -2903,6 +2907,7 @@ export class WebServer extends EventEmitter {
workingDir: muxSession.workingDir,
mode: muxSession.mode,
name: sessionName,
+ nameSource: savedState?.nameSource,
// When the session FIRST started, not when this server booted.
// Without it every recovered session was restamped `Date.now()` on
// each restart, so a week-old pane read as "created 2m ago" on the
diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts
index 33724557..6522c01a 100644
--- a/src/web/session-listener-wiring.ts
+++ b/src/web/session-listener-wiring.ts
@@ -3,7 +3,7 @@
*
* Extracted from server.ts for modularity. Provides:
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
- * - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
+ * - `createSessionListeners()` — builds all session listener handlers via dependency injection
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
*
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
@@ -29,6 +29,8 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
+import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
+import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
@@ -63,6 +65,7 @@ export interface SessionListenerRefs {
bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
+ promptSubmitted: (prompt: string) => void;
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
@@ -83,10 +86,13 @@ interface SessionListenerDeps {
cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore;
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise;
+ updateSessionName(sessionId: string, name: string): boolean;
+ /** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
+ isAutoNameEnabled(): Promise;
}
/**
- * Creates all 26 session listener handlers, capturing dependencies via closure.
+ * Creates all session listener handlers, capturing dependencies via closure.
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
@@ -451,6 +457,30 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
});
},
+
+ /**
+ * Names a placeholder tab after its first real prompt (`w3-case: fix the
+ * login redirect`), behind the synced `autoNameSessions` setting. The
+ * eligibility check comes first so the settings read costs nothing on the
+ * prompts of an already-named session; a prompt that yields no title (a
+ * slash command) leaves the session eligible for the next one.
+ */
+ promptSubmitted: (prompt: string) => {
+ if (session.nameSource !== 'placeholder') return;
+ const title = deriveAutoSessionName(prompt);
+ if (!title) return;
+ void deps
+ .isAutoNameEnabled()
+ .then((enabled) => {
+ if (!enabled) return;
+ const name = composeAutoSessionName(session.name, title, MAX_SESSION_NAME_LENGTH);
+ if (!session.applyAutoName(name)) return;
+ deps.updateSessionName(session.id, session.name);
+ deps.persistSessionState(session);
+ deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
+ })
+ .catch((err) => console.error(`[Session] auto-name failed for ${session.id}:`, err));
+ },
};
}
@@ -487,6 +517,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('bashToolEnd', refs.bashToolEnd);
session.on('bashToolsUpdate', refs.bashToolsUpdate);
session.on('attachmentRequested', refs.attachmentRequested);
+ session.on('promptSubmitted', refs.promptSubmitted);
}
/** Detach all listeners from a session (prevents memory leaks from closure references). */
@@ -522,4 +553,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('bashToolEnd', refs.bashToolEnd);
session.off('bashToolsUpdate', refs.bashToolsUpdate);
session.off('attachmentRequested', refs.attachmentRequested);
+ session.off('promptSubmitted', refs.promptSubmitted);
}
diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts
index e32b8e68..4666afe0 100644
--- a/test/mocks/mock-session.ts
+++ b/test/mocks/mock-session.ts
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
this.lastSubmitAt = Date.now();
}
+ /** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
+ trackUserInput(_data: string): void {}
+
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
diff --git a/test/routes/session-name-routes.test.ts b/test/routes/session-name-routes.test.ts
new file mode 100644
index 00000000..ba161759
--- /dev/null
+++ b/test/routes/session-name-routes.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
+ *
+ * A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
+ * auto-naming can never overwrite a name a person chose, on this server or
+ * on the one that restores the session after a restart.
+ *
+ * Uses app.inject() — no real HTTP ports needed.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
+import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
+import { Session } from '../../src/session.js';
+import { SseEvent } from '../../src/web/sse-events.js';
+
+describe('PUT /api/sessions/:id/name', () => {
+ let harness: RouteTestHarness;
+ let session: Session;
+ const updateSessionName = vi.fn(() => true);
+
+ beforeAll(async () => {
+ harness = await createRouteTestHarness(registerSessionRoutes);
+ // A REAL session, since the ownership flag lives on the class, not the mock.
+ session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
+ harness.ctx.sessions.set(session.id, session as never);
+ (harness.ctx.mux as Record).updateSessionName = updateSessionName;
+ });
+
+ afterAll(async () => {
+ await harness.app.close();
+ });
+
+ it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
+ expect(session.nameSource).toBe('placeholder');
+
+ const res = await harness.app.inject({
+ method: 'PUT',
+ url: `/api/sessions/${session.id}/name`,
+ payload: { name: 'my window' },
+ });
+
+ expect(res.statusCode).toBe(200);
+ // The harness registers the bare route; the {success,data} envelope is a server-level hook.
+ expect(res.json()).toMatchObject({ name: 'my window' });
+ expect(session.name).toBe('my window');
+ expect(session.nameSource).toBe('manual');
+ expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
+ expect(session.name).toBe('my window');
+
+ expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
+ expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
+ expect(harness.ctx.broadcast).toHaveBeenCalledWith(
+ SseEvent.SessionUpdated,
+ expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
+ );
+ // What the restore path will read back: the persisted state carries the flag.
+ expect(session.toState().nameSource).toBe('manual');
+ });
+});
diff --git a/test/session-auto-name.test.ts b/test/session-auto-name.test.ts
new file mode 100644
index 00000000..7d997071
--- /dev/null
+++ b/test/session-auto-name.test.ts
@@ -0,0 +1,274 @@
+/**
+ * @fileoverview Auto-naming a session after its first prompt (#376).
+ *
+ * The tracker sits on the raw keystroke stream, so most of these pin the
+ * per-key rules that a review of the first cut found missing: a bare Esc ate
+ * the next prompt's first character, a wheel report mid-word dropped half the
+ * prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
+ *
+ * Port: N/A (no server needed)
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { Session } from '../src/session.js';
+import {
+ SubmittedPromptTracker,
+ deriveAutoSessionName,
+ composeAutoSessionName,
+ isGeneratedSessionName,
+} from '../src/session-auto-name.js';
+
+describe('SubmittedPromptTracker', () => {
+ it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the')).toEqual([]);
+ expect(tracker.feed(' login bugs\x7f')).toEqual([]);
+ expect(tracker.feed('\r')).toEqual(['fix the login bug']);
+ expect(tracker.feed('\r')).toEqual([]);
+ expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
+ });
+
+ it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('\x1b');
+ expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
+ tracker.feed('\x1b');
+ expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
+ // Esc then digits and punctuation used to grow the escape buffer without bound.
+ tracker.feed('\x1b');
+ expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
+ // A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
+ // is Alt+s by the terminal's own encoding and stays swallowed).
+ tracker.feed('\x1b');
+ tracker.feed('\x1b');
+ expect(tracker.feed('still here\r')).toEqual(['still here']);
+ });
+
+ it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
+ });
+
+ it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
+ expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
+ expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
+ expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
+ });
+
+ it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
+ expect(tracker.feed('\x1bOB\r')).toEqual([]);
+ expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
+ expect(tracker.feed('\x10x\r')).toEqual([]);
+ expect(tracker.feed('\x12search\r')).toEqual([]);
+ expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
+ // Ctrl+C empties the composer, which also clears the taint.
+ expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
+ });
+
+ it('keeps bracketed-paste newlines inside the draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
+ 'line one line two line three plus typed',
+ ]);
+ // A paste split across chunks stays a paste.
+ tracker.feed('\x1b[200~first\r');
+ expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
+ });
+
+ it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('Fix the login bug');
+ tracker.feed('\n');
+ expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
+ });
+
+ it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
+ expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
+ expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
+ });
+
+ it('keeps the HEAD of an over-long draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
+ expect(prompt).toHaveLength(8192);
+ // Backspaces past the cap consume the overflow before the kept text.
+ const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
+ expect(again).toHaveLength(8190);
+ });
+
+ it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
+ expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
+ // Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
+ expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
+ expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
+ });
+
+ it('resumes a CSI split across chunks', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('abc\x1b[');
+ expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
+ });
+});
+
+describe('deriveAutoSessionName', () => {
+ it('takes the first sentence, drops the full stop, and bounds the length', () => {
+ expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
+ expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
+ expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
+ expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
+ expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
+ const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
+ expect(cut.endsWith('…')).toBe(true);
+ expect(cut).toMatch(/^(word )+word…$/);
+ });
+
+ it('does not cut on an abbreviation early in the prompt', () => {
+ expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
+ expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
+ });
+
+ it('returns null for commands and empties, but not for paths', () => {
+ expect(deriveAutoSessionName('/clear')).toBeNull();
+ expect(deriveAutoSessionName('/model opus')).toBeNull();
+ expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
+ expect(deriveAutoSessionName('! npm test')).toBeNull();
+ expect(deriveAutoSessionName(' ')).toBeNull();
+ expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
+ });
+
+ it('strips control bytes and ANSI before the title is persisted', () => {
+ expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
+ expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
+ });
+});
+
+describe('composeAutoSessionName', () => {
+ it('keeps the placeholder as a prefix so the case and the counter survive', () => {
+ expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
+ expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
+ });
+
+ it('honours the rename cap in UTF-16 units', () => {
+ const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
+ expect(name.length).toBeLessThanOrEqual(40);
+ expect(name.startsWith('w3-myapp: ')).toBe(true);
+ expect(name.endsWith('…')).toBe(true);
+ expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
+ });
+
+ it('recognises only the generated w/s + number + case form', () => {
+ expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
+ expect(isGeneratedSessionName('s1-shell')).toBe(true);
+ expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
+ expect(isGeneratedSessionName('alpha')).toBe(false);
+ });
+});
+
+describe('Session name ownership', () => {
+ it('infers placeholder vs manual from the name and persists the source', () => {
+ const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(placeholder.nameSource).toBe('placeholder');
+ expect(placeholder.toState().nameSource).toBe('placeholder');
+ expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
+ expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
+ expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
+ // The boot restore passes the persisted source, which outranks the inference.
+ const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
+ expect(recovered.nameSource).toBe('auto');
+ expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
+ });
+
+ it('names once: the first prompt takes it, later prompts and renames do not', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
+ expect(session.name).toBe('w1-demo: fix the login bug');
+ expect(session.nameSource).toBe('auto');
+ expect(session.applyAutoName('w1-demo: 1')).toBe(false);
+ expect(session.name).toBe('w1-demo: fix the login bug');
+
+ session.name = 'mine';
+ expect(session.nameSource).toBe('manual');
+ expect(session.applyAutoName('other')).toBe(false);
+ expect(session.name).toBe('mine');
+ });
+
+ it('consumes the first prompt even when the composed name is unchanged', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(session.applyAutoName('w1-demo')).toBe(false);
+ expect(session.nameSource).toBe('auto');
+ });
+});
+
+describe('Session promptSubmitted', () => {
+ function withFakePty(session: Session): ReturnType {
+ const write = vi.fn();
+ (session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
+ return write;
+ }
+
+ it('emits for user input only, after the bytes reached the PTY', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+
+ // No PTY yet: the write fails and nothing is reported.
+ expect(session.write('lost\r', { fromUser: true })).toBe(false);
+ expect(prompts).toEqual([]);
+
+ const write = withFakePty(session);
+ expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
+ expect(prompts).toEqual([]);
+ expect(session.write('fix the ', { fromUser: true })).toBe(true);
+ expect(session.write('login bug\r', { fromUser: true })).toBe(true);
+ expect(prompts).toEqual(['fix the login bug']);
+ expect(write).toHaveBeenCalledTimes(3);
+ // The pane's last-Enter stamp is kept for EVERY write, user or not.
+ expect(session.lastSubmitAt).toBeGreaterThan(0);
+ });
+
+ it('never feeds the tracker for a shell session', () => {
+ const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ withFakePty(session);
+ expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
+ session.trackUserInput('cd src\r');
+ expect(prompts).toEqual([]);
+ });
+
+ it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ withFakePty(session);
+ session.write('Fix the login bug', { fromUser: true });
+ session.trackUserInput('\n');
+ session.write('Also add tests.\r', { fromUser: true });
+ expect(prompts).toEqual(['Fix the login bug Also add tests.']);
+ });
+
+ it('reports through writeViaMux only when the mux accepted the input', async () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ const sendInput = vi.fn(async () => false);
+ (session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
+ (session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
+
+ expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
+ expect(prompts).toEqual([]);
+ sendInput.mockResolvedValue(true);
+ expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
+ expect(prompts).toEqual(['delivered']);
+ expect(await session.writeViaMux('/clear\r')).toBe(true);
+ expect(prompts).toEqual(['delivered']);
+ });
+});
diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts
index 74ea312c..1173fecb 100644
--- a/test/session-listener-wiring.test.ts
+++ b/test/session-listener-wiring.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
+import { SseEvent } from '../src/web/sse-events.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -20,4 +21,70 @@ describe('session listener wiring', () => {
);
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
+
+ /** The listener reads the setting asynchronously; let its promise chain settle. */
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+
+ function autoNameDeps(session: Session, enabled: boolean) {
+ const deps = {
+ updateSessionName: vi.fn(() => true),
+ persistSessionState: vi.fn(),
+ broadcast: vi.fn(),
+ getSessionStateWithRespawn: vi.fn(() => session.toState()),
+ isAutoNameEnabled: vi.fn(async () => enabled),
+ };
+ return {
+ deps,
+ refs: createSessionListeners(session, deps as unknown as Parameters[1]),
+ };
+ }
+
+ it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
+ const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
+ const { deps, refs } = autoNameDeps(session, true);
+
+ // A slash command yields no title and leaves the session eligible; the
+ // setting is not even read for it.
+ refs.promptSubmitted('/clear');
+ await flush();
+ expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
+ expect(session.name).toBe('w1-demo');
+
+ refs.promptSubmitted('整理登录模块并补充测试');
+ await flush();
+ expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
+ expect(session.nameSource).toBe('auto');
+ expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
+ expect(deps.persistSessionState).toHaveBeenCalledWith(session);
+ expect(deps.broadcast).toHaveBeenCalledWith(
+ SseEvent.SessionUpdated,
+ expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
+ );
+
+ // The second prompt never reaches the setting: the tab is named.
+ refs.promptSubmitted('1');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
+ });
+
+ it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
+ const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
+ const { deps, refs } = autoNameDeps(session, false);
+
+ refs.promptSubmitted('fix the login bug');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('w1-demo');
+ // Still a placeholder: flipping the setting on names the NEXT prompt.
+ expect(session.nameSource).toBe('placeholder');
+ expect(deps.updateSessionName).not.toHaveBeenCalled();
+
+ session.name = '人工命名';
+ refs.promptSubmitted('新的任务不能覆盖人工命名');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('人工命名');
+ expect(deps.persistSessionState).not.toHaveBeenCalled();
+ });
});