mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02bbf13b3c | ||
|
|
da91b4353b | ||
|
|
b6d0f1fa32 | ||
|
|
65e994d29a | ||
|
|
f18dccace1 | ||
|
|
2ee2eacb4b | ||
|
|
c4f6eb1e5e | ||
|
|
ab83d8ffec | ||
|
|
1829fe91af | ||
|
|
d74cde759b | ||
|
|
853681f970 | ||
|
|
ed983f898b | ||
|
|
54a930c80e | ||
|
|
4c332c6141 | ||
|
|
253599ce9c | ||
|
|
3e1a0e679f | ||
|
|
7ec48adcc8 | ||
|
|
9841f4ffb9 | ||
|
|
d8688dc143 | ||
|
|
23fae0c5af | ||
|
|
e4699159e9 | ||
|
|
da085f5f7f | ||
|
|
23e32b22d5 | ||
|
|
e82380e14a | ||
|
|
c0423bf560 | ||
|
|
4f5678fac4 | ||
|
|
7dfb4acf24 | ||
|
|
d3f851a5e5 | ||
|
|
5f8d4de443 | ||
|
|
00b32ad2b8 | ||
|
|
b00ab3ceea | ||
|
|
134e200aec | ||
|
|
a51563ce1f | ||
|
|
ca5fe1ab3e | ||
|
|
9cd10afdc9 | ||
|
|
975705ad87 | ||
|
|
93a1042bb3 | ||
|
|
a628737d1f | ||
|
|
015b865f56 | ||
|
|
33f77c4680 | ||
|
|
6261b6f655 | ||
|
|
d1bc0c517d | ||
|
|
c30dfaf0e7 | ||
|
|
15ae5f5d81 | ||
|
|
14de2b7012 | ||
|
|
cdceede33d | ||
|
|
2034719d61 | ||
|
|
7c62b16e5f | ||
|
|
d15d979a33 | ||
|
|
858b15e3f5 | ||
|
|
b330f1d9e8 | ||
|
|
c14171b534 | ||
|
|
acd9ffedc8 | ||
|
|
921933775b | ||
|
|
f6a1f06633 | ||
|
|
dab8e6643c | ||
|
|
4cda150493 | ||
|
|
3af36f7c34 | ||
|
|
49797e37dd | ||
|
|
c614331d60 | ||
|
|
9cfd8e8989 | ||
|
|
8fe393826b | ||
|
|
7a340fe7bc | ||
|
|
f3c615b669 | ||
|
|
82f81d21c4 | ||
|
|
c173ae0264 | ||
|
|
e9dd55e5fd | ||
|
|
dd96f252ea | ||
|
|
74194e4fc0 | ||
|
|
c45c6c3846 | ||
|
|
17b141dc25 | ||
|
|
57f326ab8f | ||
|
|
6b0b6d10ad | ||
|
|
c9ea8bbac5 | ||
|
|
1795a138b3 | ||
|
|
e3a2fb767f | ||
|
|
3f8c8e99d1 | ||
|
|
88bb98de43 | ||
|
|
687e9d7565 | ||
|
|
5d81cc01ca | ||
|
|
f49249fb2f | ||
|
|
bc3f9f8a37 | ||
|
|
97acfc61c3 | ||
|
|
c27363459d | ||
|
|
737c2ed7f8 | ||
|
|
bb24d2c256 | ||
|
|
bc1821661f | ||
|
|
74c9879359 | ||
|
|
499d3d6e4d | ||
|
|
9b29666e03 | ||
|
|
aec6516638 | ||
|
|
c59f006bb6 | ||
|
|
b2ac6c1bd9 | ||
|
|
7b1150ca4f | ||
|
|
95a1f540b5 | ||
|
|
e7b7e90a1b | ||
|
|
84f8a8a2fe | ||
|
|
6ad9145417 | ||
|
|
ab4a868688 | ||
|
|
35b2c1baa5 | ||
|
|
bf860382a2 | ||
|
|
aa487f13ce | ||
|
|
0919f9da62 | ||
|
|
75b272ff0a | ||
|
|
1385415e53 | ||
|
|
954a9ac26a | ||
|
|
5fd6c5dd44 | ||
|
|
8b5fc974b0 | ||
|
|
a52abd9f96 | ||
|
|
008dfddc23 | ||
|
|
32549789c7 | ||
|
|
29d9a55eb6 | ||
|
|
ef812236b0 | ||
|
|
b51abe2c27 | ||
|
|
eb8958ddd0 | ||
|
|
953a560eee | ||
|
|
dc89f05b14 | ||
|
|
bb73400afa | ||
|
|
9d7dd2ab62 | ||
|
|
3f88226d50 | ||
|
|
e5ae2826c6 | ||
|
|
85b69e0923 | ||
|
|
6682231d68 | ||
|
|
e140132e45 | ||
|
|
6475c010a6 | ||
|
|
64c8048dda | ||
|
|
0566ea3453 | ||
|
|
6ef3b2ba2e | ||
|
|
74fe2cad9f | ||
|
|
aa2deea73e | ||
|
|
5d7fdb528b | ||
|
|
64cf8384f2 | ||
|
|
596c08d20c | ||
|
|
1d0c3650f9 | ||
|
|
b9afd5a57e | ||
|
|
14a911b4f6 | ||
|
|
9b1d269943 | ||
|
|
4e5d0dcbd6 | ||
|
|
f9d6c4f0c3 | ||
|
|
09d6bb9eb0 | ||
|
|
a922d301b1 | ||
|
|
abca552676 | ||
|
|
12a996b107 | ||
|
|
458e751a33 | ||
|
|
dab432b3fd | ||
|
|
f744719650 |
+3
-2
@@ -2,6 +2,9 @@
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
|
||||
# In-session decision scratchpad (context-survival mechanism, not a deliverable)
|
||||
DECISIONS.md
|
||||
# Written by install.sh into end-user clones when setup finishes
|
||||
.install-complete
|
||||
|
||||
@@ -93,8 +96,6 @@ packages/gesture-control/.vite/
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
# Unfinished TUI (local development only)
|
||||
src/tui/
|
||||
.claude/
|
||||
media-assets/
|
||||
commands
|
||||
|
||||
+141
@@ -1,5 +1,146 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- OMP (Oh My Pi) as a tenth run mode, mode-faithful Resume for external CLIs, and a cleaner plan-usage chip.
|
||||
|
||||
**OMP (`omp`) run mode** (#353): Oh My Pi joins Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build and DeepSeek Harness as a run mode, in local, Docker and remote-SSH sessions: toolbar dropdown, welcome button, phone overview, command palette, clone-repo brain picker, cron agent types, tab badges and per-mode colours, plus `GET /api/omp/status`, a `codeman doctor` entry, install.sh detection and the docker agent image. The resolver leads with `~/.local/bin` (the upstream installer's real target) and demands `omp/<semver>` from `--version`, so an unrelated binary with the same three-letter name is never spawned. Past omp conversations appear in Past Sessions, read from omp's own session files (the header line carries the real working directory, so nothing has to reverse-engineer omp's directory mangling), and a respawned or resumed omp session is pinned to an exact conversation with `--resume <id>` instead of omp's newest-file `--continue`. Review hardening before merge: the pin is resolved only at the moment a respawn is actually confirmed (an eager resolve on boot recovery used to alias two omp tabs in one case directory onto one conversation), candidates are verified against their own header `cwd` and claimed process-wide so siblings cannot double-pin; `OMP_*` joins the env-override allowlist and `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are clamped for non-granted owners in multi-user mode, the same shape as `DEEPSEEK_BASE_URL`. Known and documented: omp's own knobs are mostly `PI_*` (it is a pi fork), its default `tools.approvalMode` is `yolo`, and in-container `--resume` pinning does not reach a Docker omp pane.
|
||||
|
||||
**Resume keeps the row's own CLI** (#353): clicking Resume on an OpenCode, Pi, Grok, DeepSeek or OMP row used to create a plain Claude session, since the create request never carried the row's mode. Resume now relaunches in the row's own mode with that CLI's continue flag, and retires the stale row it came from so three clicks no longer leave three copies of the same name. Codex, Gemini and Antigravity rows have no continuation wired yet, so their rows are deliberately left in place. `DELETE /api/sessions/:id` accepts a persisted-only session (ownership enforced through the same helper as live lookups, 404 rather than 403 so nothing leaks) and broadcasts `session_deleted` so other tabs drop the row too.
|
||||
|
||||
**Plan-usage chip drops the provider label when there is only one**: a machine with only Claude limits rendered `CLAUDE 5H 60% 7D 23%`, a 46px label naming the only thing it could be. The name exists to tell two rows apart, so it now appears only when both Claude and Codex have windows; the tooltip still names the provider either way.
|
||||
|
||||
### Thanks
|
||||
- @timkjr for #353, and for turning every review finding around within a day
|
||||
|
||||
## 1.23.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Codex plan usage in the header chip, a visible inline rename in the session sidebar, and an installer that no longer loses Tailscale access on a re-run.
|
||||
|
||||
**Codex plan usage in the header chip** (#346): the plan-usage chip used to show Claude's 5-hour and weekly limits without saying they were Claude's, which stops being a detail the moment you run more than one CLI. It now renders one compact row per provider, Claude above Codex, each labelled and colour-coded by how much is used up. Claude's numbers still come from Codeman's marked `statusLine.command` exporter; Codex's come from the signed-in host CLI's read-only `account/rateLimits/read` app-server request at startup and every five minutes, so credentials stay inside the CLI and no auth material reaches the browser. Only the main `codex` bucket is read (model-specific buckets such as Spark are separate limits and are deliberately excluded), and the Codex row is omitted entirely when no 5-hour or weekly window is available, rather than inventing one.
|
||||
|
||||
**Inline rename is visible in the session sidebar** (#345): starting a rename on a sidebar row opened a focused input you could not see. The row's ellipsis clamp was still painting over the live editor, so text and caret went in blind. The sidebar now gets the same unclamped editor layout the vertical tab rail already had. Covered by a Chromium regression test that asserts the painted `overflow` and the input's measured width, not just the class name.
|
||||
|
||||
**install.sh keeps Tailscale access on a re-run**: a re-run whose build failed could drop a working Tailscale binding instead of preserving it. The installer now offers Tailscale setup again on re-run rather than losing it, and the README describes the three-way network-access prompt (Tailscale / LAN / local-only) as it actually behaves.
|
||||
|
||||
### Thanks
|
||||
- @JackStuart for #346
|
||||
- @fibr for #345
|
||||
- @tailong-wu for #342, whose analysis of the terminal refresh replay loop matched a fix that had landed on master a few hours earlier
|
||||
|
||||
## 1.23.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a fresh-Linux install failure, and bound the browser terminal's live write queue.
|
||||
|
||||
**install.sh now installs a build toolchain.** Reported against a stock Ubuntu 24 server: node-pty publishes prebuilt binaries for darwin and win32 only, so on Linux it is always compiled from source during `npm install`. The installer set up Node, tmux and git but never a compiler, so a machine without `build-essential` died deep inside node-gyp with `not found: make` — which reads like an npm bug rather than a missing system package. `make`, a C++ compiler and `python3` are now checked up front exactly like git and tmux, installed per distro (apt / dnf / pacman / apk / zypper) behind the same consent prompt, and re-verified afterwards rather than assumed. If `npm install` fails anyway — including on `install.sh update` — it now names the missing tools and the command that installs them instead of leaving a node-gyp stack trace as the last word.
|
||||
|
||||
**Bounded live xterm backpressure** (#339): live output is now one chunk in flight at a time, released by xterm's own parse callback, so xterm's private WriteBuffer can no longer hide an unbounded backlog behind the browser's 128 KiB render cap; queued, loading and incoming bytes all count against that cap. Automatic drop recovery for a shell stays on the bounded 1 MiB tail — a 100k-line shell capture is tens of MiB, and parsing it on the main thread is the freeze the cap exists to prevent — while TUI modes still recover full history behind the existing downgrade guard. Duplicate SSE terminal events are dropped before JSON parsing while WebSocket owns terminal I/O, and recovery is single-flight per active session. Follow-up hardening: the three write-queue reset paths now also release the in-flight gate, so a parse callback that never lands cannot leave live output permanently stalled.
|
||||
|
||||
**File Viewer searches the workspace** (#340): the File Viewer search box now queries the server-side file search endpoint with a 250 ms debounce and strict response validation, instead of filtering only the part of the tree already loaded. Tree and search state are scoped to the active session, the hidden-file preference and independent request epochs, so a stale response cannot repaint the panel; cached-tree restoration, directory results and reset behaviour survive session switches and both panel-hide paths.
|
||||
|
||||
### Thanks
|
||||
- @dignfei for #339
|
||||
- @aakhter for #340
|
||||
|
||||
- 858b15e: Search the full session workspace from File Viewer while keeping results scoped to the active session and hidden-file preference.
|
||||
|
||||
## 1.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- DeepSeek Harness as a ninth run mode, DeepSeek agent workers, and detailed rows for the vertical tab rail.
|
||||
|
||||
**DeepSeek Harness (`dsh`) run mode** (#337): DeepSeek's plugin-native agent framework joins Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi and Grok as a run mode. The harness is a profile launcher rather than an agent, so availability is two questions (binary AND a pane-capable profile): the Run button gates on both, a missing terminal profile is offered as a one-click install (`POST /api/deepseek/install-profile`, the only endpoint in Codeman that installs third-party code, fenced accordingly), and the resolver demands the harness's own help banner so Debian's unrelated `dsh` (dancer's shell) can never be spawned. Its permission switch is the `DSH_PERMISSION_MODE` env export (the harness has no bypass flag), injected via tmux setenv and clamped for non-granted owners in multi-user mode, including the env-override path. The community TUI's supervisor-reporting contract makes deepseek the first non-Claude mode with REAL lifecycle signals: a generated status shim turns its idle/working/blocked reports into definitive `stop`/`permission_prompt`/`agent_working` hook events, so dsh sessions get real respawn triggers, real wait signals and red "needs you" alerts instead of output-stabilization guesswork. The vendor's browser UI opens as a managed web tab through a background `dsh web` fenced to Codeman's origin. Docker image support included.
|
||||
|
||||
**DeepSeek agent workers** (#341): the codeman agent skill can spawn and drive dsh workers like claude ones — tasked, waited on and read with the same calls. `GET /api/sessions/:id/last-response` reads the harness's real zstd transcript (one frame per append; the reader walks frame boundaries itself, since a naive decode silently truncates to the first frame), distinguishes real prompts from plugin-injected context, and reports a failed turn's provider error instead of an empty answer.
|
||||
|
||||
**Vertical tab rail: detailed rows** (#338): the vertical rail can now show the home screen's per-session line (created stamp, state duration, status pill) via the new per-device `tabRailDetail` setting (default detailed; `simple` restores the 1.22.0 rows). One shared row model and one gate (`isRichTabRows()`) keep the rail, the rich sidebar and both home screens in agreement about what "working" means. A never-sized rail opens at the 320px Wide preset; below 288px the created stamp is dropped, below 240px rows fall back to simple. Also fixes Escape during an inline tab rename committing an empty name (the session then displayed its folder name).
|
||||
|
||||
**Review hardening across all three** (post-review commits on each PR): multi-user owners without the bypass grant can no longer redirect the server's forwarded `DEEPSEEK_API_KEY` via a `DEEPSEEK_BASE_URL` override; waits on `stop`/`blocked` are refused for docker/remote dsh sessions (their status bridge cannot reach the harness) and docker/remote dsh sessions keep the pane reader (their transcripts are not local); dsh approvals are alerts answered in the terminal, never blind keystrokes into a third-party TUI; the status shim forwards the contract's `--seq` token (stale retried reports are dropped server-side) and treats 4xx as permanent so a misconfigured session cannot rate-limit the hook endpoint for the whole instance; concurrent DeepSeek web-UI starts are serialized; cron deepseek jobs run the same launch gate as the HTTP paths; the installer's dsh identity probe is stdin-closed, bounded and memoized; transcript reads are memoized per (path, mtime, size) so 1s polling stops decoding unchanged files; the rail's width dialog, compact-threshold folder rows and reset affordances are rich-aware.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b330f1d: Vertical tab rail: detailed rows, and a rename cancel that no longer wipes the name.
|
||||
|
||||
The vertical rail (Tab Orientation → Vertical) now draws the same per-session
|
||||
line the home screen and the rich sidebar draw — when the session was created,
|
||||
how long it has been in the state it is in, the folder it runs in, and a status
|
||||
pill — instead of just the name. New per-device setting **Vertical Rail Rows**
|
||||
(`tabRailDetail`, App Settings → Appearance → Tabs) with `Detailed` as the
|
||||
default and `Simple (name only)` as the opt-out. A rail that has never been
|
||||
sized now opens at 320px (the existing Wide preset) so the line fits; a narrower
|
||||
rail sheds the created stamp below 288px and falls back to simple rows below
|
||||
240px.
|
||||
|
||||
Also fixes a data-loss bug in the inline tab rename that predates the rail:
|
||||
pressing Escape cleared the input and blurred it, and the blur handler commits —
|
||||
so cancelling a rename stored an EMPTY session name and the tab fell back to its
|
||||
folder label. Escape now cancels without a request, in every layout.
|
||||
|
||||
## 1.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3f8c8e9: Add Grok Build (xAI `grok`) as a seventh CLI run mode. SessionMode gains 'grok', with its own resolver (version-probed, since the name has npm squatters; GET /api/grok/status surfaces path + version), GrokConfig (model, alwaysApprove -> --always-approve, resume/continue), GROK*\*/XAI*\* env allowlist entries, the multi-user only-if-sent bypass clamp, Docker (own image step + per-file credential seeding) and remote-SSH command defaults, cron agentType, run-mode/welcome/tab UI with a charcoal identity, and docs (grok-integration.md + plan). Verified end to end against grok 1.0.5 on an isolated instance.
|
||||
- 74194e4: Add the owner-scoped tab-layout model, persistence, API, lifecycle repair, and synchronized legacy ordering foundation.
|
||||
- e3a2fb7: Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix the file preview's dead pop-out control: a real detach button now opens the previewed file in a browser tab (raw route for PDFs/images/media/text, converted-PDF preview for docx/pptx) and the copy button reports when a preview has no text to copy instead of silently doing nothing. Review-driven hardening for the new tab features: PUT /api/session-order drops unknown ids again instead of rejecting the whole write (a session deleted inside the browser's debounce window could silently lose the user's reorder), a failed mux restore no longer blocks explicit session/webview deletion for the process lifetime (the automated stale sweep stays fail-closed), and the vertical rail gains the axis-awareness the sidebar-only predicates missed: correct drag-reorder insertion, active-tab scroll-into-view, floating windows anchored beside rail tabs, connector redraws on rail scroll, server-seeded orientation applied on first load, a pre-paint stamp so vertical mode no longer flashes through the header strip, and a 12px session-name default matching the sidebar's historical size so untouched installs are not restyled.
|
||||
|
||||
## 1.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **`codeman tui`: a terminal dashboard for your sessions.** For the times you are in SSH or Termius instead of a browser. The web UI remains the primary surface and bare `codeman` still prints help, so the dashboard itself is strictly additive.
|
||||
|
||||
Sessions are grouped NEEDS YOU / WORKING / IDLE / RECENT in the same status language as the web tabs and the phone overview, and the states come from the server (hooks, idle confirmation, the approvals inbox) over the existing HTTP/SSE API rather than being screen-scraped. That is what lets the dashboard answer a permission dialog instead of only reporting one.
|
||||
- `↑↓`/`j`/`k` select; `1`-`9`, `[`/`]` and `Tab` switch between sessions
|
||||
- `Enter` attaches and hands the terminal to tmux; **`F1` comes back**, one key, no modifier. Inside the pane a bar across the top carries the session strip and `Alt+1`..`Alt+9` switch without returning to the dashboard first
|
||||
- `Enter` on a RECENT row resumes that conversation; on a session whose pane has died it refuses and offers `r` to resume it in a fresh pane
|
||||
- `y`/`n`/digits answer the selected session's pending permission or question card (the server re-captures the pane first, so a keystroke can never land in the composer)
|
||||
- `p` sends a one-line prompt without attaching, `x` kills (`y` confirms), `n` starts a session and opens straight into it
|
||||
- `/` cross-session search, `g` away digest, `?` help, live preview pane, plan-usage chip in the header, a terminal bell when a new approval arrives
|
||||
- `codeman tui --list` and `codeman tui <n>` are scriptable fast paths; with no server running it lists panes straight from the instance's tmux socket, attach-only, and upgrades live when the server comes back
|
||||
|
||||
Narrow terminals (under 72 columns, a phone SSH client) drop the preview and get a single-column layout. `NO_COLOR`, non-UTF-8 glyph fallback and a non-TTY refusal are all handled. Zero new dependencies: hand-rolled ANSI over chalk and commander. User guide: `docs/tui.md`.
|
||||
|
||||
**Breaking: the `sc` tmux chooser is retired.** `scripts/tmux-chooser.sh` is deleted and `install.sh` no longer creates the `tmux-chooser` symlink or the `sc` alias; it sweeps both up instead, on update and on uninstall. `codeman tui` replaces it and does the job better: `sc` numbered its entries globally but only accepted a single `[1-9]` keypress, so sessions 10+ were listed and could not be selected, and it inferred nothing about what an agent was doing. The alias cleanup is marker-owned, matching the exact line the installer wrote, so a user's own `alias sc=` for another tool is untouched.
|
||||
|
||||
**CLI polish that came with it.**
|
||||
- New shared style kit (`src/cli-style.ts`) used across the CLI: semantic palette, glyphs, width-aware table, spinner, confirm.
|
||||
- `codeman doctor` is colorized and its table is measured, so the "Antigravity CLI" label no longer pushes its row out of column. `--json` output is unchanged.
|
||||
- `codeman web` no longer prints its "running at" line twice, and the server's non-loopback security warning is painted like the CLI's (chalk degrades off a TTY, so journald and `web.log` stay free of escape codes).
|
||||
- Spinners on the silent up-to-30s waits in `codeman web -d`, `codeman web --stop` and `codeman service install`.
|
||||
- `codeman reset` asks a real y/N confirmation on a TTY; non-interactive callers keep the old `--force` refusal.
|
||||
- `codeman list` and `codeman session list` share one renderer instead of drifting copies.
|
||||
- `codeman attach` is described correctly in the README (it shows an attachment card for a local file).
|
||||
- `test/cli-commands.test.ts` now derives its inventory from the real commander program instead of a hand-written fixture that had drifted.
|
||||
|
||||
**Internal.** New `tmux -L` callers resolve the socket through `resolveTmuxSocketName()`, now exported from `config/instance.ts`, so a second process can never point a beta instance at prod's panes. CLAUDE.md and `docs/architecture-invariants.md` both record the rule.
|
||||
|
||||
### Thanks
|
||||
|
||||
The TUI went through seven rounds of beta testing over PuTTY/SSH by **@Ark0N**, which is where the way out of an attach, the session strip, the preview repaint handling and the glyph set all came from.
|
||||
|
||||
## 1.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal input and scrollback fixes (PRs #327, #331):
|
||||
- IME punctuation preserved (#327): keyCode 229 / `Process` key events are now delegated to xterm's CompositionHelper instead of being suppressed, so an active Chinese IME committing numbers and full-width punctuation (,。!? and friends) reaches the terminal correctly. The CJK input field sends the browser's committed text instead of guessing from `KeyboardEvent.key`, and the redundant Android orphan-input fallback is removed so xterm is the single input owner.
|
||||
- Shell history replay bounded (#331): selecting a Shell session loads a bounded 1 MiB tail instead of replaying the entire multi-megabyte tmux scrollback on xterm's main thread; full history stays available via the explicit "Load full history" action. tmux history limits now apply correctly on both legacy tmux (global default set in the same command queue before pane creation) and tmux 3.7+ (per-pane targeting that never resizes or trims unrelated live panes). Also adds `Server-Timing` and `[TERMINAL-PERF]` timing stages for terminal loads, fixes `scrollToLastNonEmptyLine` double-counting scrollback rows, and keeps live output ordered behind snapshot replays.
|
||||
|
||||
### Thanks
|
||||
- @dignfei for both fixes: the IME punctuation root-cause fix (#327) and the bounded shell history replay with the tmux history-limit correctness work (#331).
|
||||
|
||||
## 1.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Terminal - One Dashboard • Any Device</em>
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • OMP • Terminal - One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,7 +27,7 @@
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||
</p>
|
||||
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi 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, 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, six CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
|
||||
- **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)
|
||||
- **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
|
||||
@@ -61,14 +61,14 @@ The installer asks before every system change, and re-running the same line upda
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
|
||||
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
|
||||
- **Network or local-only, your choice.** The installer asks whether the dashboard should be reachable from other devices on your network (`0.0.0.0`, the default, with a strongly recommended password prompt) or from this machine only (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **How it's reachable, your choice.** The installer offers three ways to reach the dashboard: **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already in use, your existing binding on a re-run), and a bare Enter never pulls in new software. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **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), or [Pi](https://pi.dev) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the six 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 Claude Code or OpenCode, or you can skip and install one yourself later. After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
|
||||
```
|
||||
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([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), or [Pi](https://pi.dev)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([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)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -253,7 +253,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`, or `Terminal` (plain shell). |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `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`. |
|
||||
|
||||
@@ -285,7 +285,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
- **Phone/tablet** — the UI is fully touch-optimized; scan the desktop **QR code** to log in without typing a password.
|
||||
- **Outside your network** — `./scripts/tunnel.sh start` opens a Cloudflare tunnel (set `CODEMAN_PASSWORD` first).
|
||||
- **SSH** — the `sc` chooser attaches to any session from a terminal (`sc` interactive, `sc 2` quick-attach, `sc -l` list).
|
||||
- **SSH** — `codeman tui` is a full-screen dashboard in the terminal (`codeman tui --list` to list, `codeman tui 2` to attach straight to one).
|
||||
|
||||
### 7. Operate & maintain
|
||||
|
||||
@@ -437,7 +437,7 @@ 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/<name>` 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**, or **Pi** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) and [`docs/pi-integration.md`](docs/pi-integration.md)
|
||||
- **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)
|
||||
- **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
|
||||
@@ -460,7 +460,7 @@ Run a case inside its own hardened Docker container instead of directly on your
|
||||
- **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.
|
||||
- **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 / 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.
|
||||
- **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).
|
||||
@@ -658,17 +658,19 @@ These run for **every** request — before auth, even on the default no-password
|
||||
|
||||
---
|
||||
|
||||
## SSH Alternative (`sc`)
|
||||
## Terminal UI (`codeman tui`)
|
||||
|
||||
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
|
||||
A full-screen dashboard for your sessions, in the terminal. Same states as the web UI, because it is a client of the same server:
|
||||
|
||||
```bash
|
||||
sc # Interactive chooser
|
||||
sc 2 # Quick attach to session 2
|
||||
sc -l # List sessions
|
||||
codeman tui # the dashboard
|
||||
codeman tui --list # numbered session list, then exit (scriptable)
|
||||
codeman tui 2 # attach straight to session 2 of that list
|
||||
```
|
||||
|
||||
Single-digit selection (1-9), color-coded status, token counts, auto-refresh. Detach with `Ctrl+A D`.
|
||||
Sessions are grouped **NEEDS YOU → WORKING → IDLE → RECENT**, longest-waiting first. `↑↓`/`j`/`k` select, `1`-`9` and `[`/`]` switch between sessions, `Enter` attaches into the tmux pane (**`F1`** to come back). Inside a pane the bar across the top keeps the session strip visible and `Alt+1`-`Alt+9` switch without leaving. `y`/`n`/digit answer a pending permission dialog right from the list, `p` sends a one-line prompt, `n` starts a session and opens straight into it, `x` kills one (`y` confirms), `/` searches, `g` shows the away digest, `?` is help, `q` quits. Below 72 columns it drops the preview pane and becomes a single-column list, so it stays usable in Termius on a phone. With no server running it still starts in attach-only degraded mode.
|
||||
|
||||
The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for the full guide.
|
||||
|
||||
---
|
||||
|
||||
@@ -793,7 +795,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
|
||||
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.
|
||||
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` 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.
|
||||
|
||||
### Recipes
|
||||
|
||||
@@ -893,7 +895,9 @@ codeman session start -d /path/to/repo # (s) start a session
|
||||
codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman attach <path> # attach a Claude hook context
|
||||
codeman attach <path> # show an attachment card for a local file
|
||||
codeman tui --list # numbered session list (plain text when piped)
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
@@ -1007,7 +1011,7 @@ flowchart TB
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP</small>"] BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+6
-20
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
||||
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [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)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这六个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
|
||||
你至少需要安装一个 AI 编程 CLI —— [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)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
|
||||
```
|
||||
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([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))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([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))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -221,7 +221,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
| 字段 | 作用 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 |
|
||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi` 或 `Terminal`(普通 shell)。 |
|
||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok` 或 `Terminal`(普通 shell)。 |
|
||||
| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||
|
||||
@@ -253,7 +253,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
- **手机/平板** —— UI 完全触控优化;扫描桌面上的**二维码**即可免密码登录。
|
||||
- **网络之外** —— `./scripts/tunnel.sh start` 打开一条 Cloudflare 隧道(先设置 `CODEMAN_PASSWORD`)。
|
||||
- **SSH** —— `sc` 选择器可从终端附着任意会话(`sc` 交互式,`sc 2` 快速附着,`sc -l` 列表)。
|
||||
- **SSH** —— `codeman tui` 是终端里的全屏会话面板(`codeman tui --list` 列出,`codeman tui 2` 直接附着到某个会话)。
|
||||
|
||||
### 7. 运维与维护
|
||||
|
||||
@@ -394,7 +394,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
## 更多特性
|
||||
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini** 或 **Pi**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`PI_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md) 与 [`docs/pi-integration.md`](docs/pi-integration.md)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi** 或 **Grok**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`PI_*`、`GROK_*`/`XAI_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md) 与 [`docs/grok-integration.md`](docs/grok-integration.md)
|
||||
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||
@@ -615,20 +615,6 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
||||
|
||||
---
|
||||
|
||||
## SSH 替代方案(`sc`)
|
||||
|
||||
如果你更喜欢 SSH(Termius、Blink 等),`sc` 命令是一个便于拇指操作的会话选择器:
|
||||
|
||||
```bash
|
||||
sc # 交互式选择器
|
||||
sc 2 # 快速附着到会话 2
|
||||
sc -l # 列出会话
|
||||
```
|
||||
|
||||
单数字选择(1–9)、颜色编码的状态、token 计数、自动刷新。用 `Ctrl+A D` 分离。
|
||||
|
||||
---
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
> Ctrl 绑定在 macOS 上也接受 Cmd。
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
'test/opencode-resize.test.ts',
|
||||
'test/webgl-fallback.test.ts',
|
||||
|
||||
+62
-2
@@ -51,6 +51,51 @@ RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force \
|
||||
&& pi --version
|
||||
|
||||
# Grok Build (`grok`, xAI) is NOT on npm: a standalone ~160MB Rust binary through
|
||||
# xAI's installer, which targets $HOME/.grok/bin with no --dir override. At build
|
||||
# time that is root's home and unreachable by the `agent` user, so copy the binary
|
||||
# into /usr/local/bin and drop root's ~/.grok in the same layer so the image does
|
||||
# not carry the download twice. The staging cp -T is what makes this survive the
|
||||
# installer's own behavior EITHER way: newer installers already symlink
|
||||
# /usr/local/bin/grok -> /root/.grok/bin/grok, and a direct `cp -L` onto that
|
||||
# symlink fails with "same file" (2026-08-24 rebuild), while removing the link
|
||||
# first and copying fresh works for both old and new installers.
|
||||
RUN curl -fsSL https://x.ai/cli/install.sh | bash \
|
||||
&& cp -L /root/.grok/bin/grok /usr/local/bin/grok.real \
|
||||
&& rm -f /usr/local/bin/grok \
|
||||
&& mv /usr/local/bin/grok.real /usr/local/bin/grok \
|
||||
&& chmod 755 /usr/local/bin/grok \
|
||||
&& rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
|
||||
&& grok --version
|
||||
|
||||
# DeepSeek Harness (`dsh`). A normal npm package, but the ONLY entry here whose
|
||||
# binary runs nothing on its own: `dsh` is a profile launcher, and DeepSeek ships
|
||||
# only `web` and `headless`, so without an interactive profile a
|
||||
# `mode: 'deepseek'` container would start a pane that dies on arrival. The
|
||||
# profile itself is installed further down, into the `agent` HOME, because
|
||||
# Codeman deliberately does NOT seed `profiles/` from the host: it is a
|
||||
# per-profile node_modules tree, host-arch-specific and far too large to copy on
|
||||
# every container start.
|
||||
RUN npm install -g @deepseek-ai/dsh \
|
||||
&& npm cache clean --force \
|
||||
&& dsh --version
|
||||
|
||||
# OMP (Oh My Pi) is NOT on npm: a standalone binary via omp.sh's installer, which
|
||||
# targets $HOME/.local/bin with no --dir override (verified 2026-08-27 — the
|
||||
# resolver's OMP_SEARCH_DIRS lists ~/.omp/bin first, which turned out to be the
|
||||
# WRONG guess for the installer's actual target; build this step for real
|
||||
# rather than trust that ordering). At build time $HOME is root's home and
|
||||
# unreachable by the `agent` user, so copy the binary into /usr/local/bin and
|
||||
# drop root's ~/.local/bin/omp in the same layer so the image does not carry
|
||||
# the download twice.
|
||||
RUN curl -fsSL https://omp.sh/install | sh \
|
||||
&& cp -L /root/.local/bin/omp /usr/local/bin/omp.real \
|
||||
&& rm -f /usr/local/bin/omp \
|
||||
&& mv /usr/local/bin/omp.real /usr/local/bin/omp \
|
||||
&& chmod 755 /usr/local/bin/omp \
|
||||
&& rm -f /root/.local/bin/omp \
|
||||
&& omp --version
|
||||
|
||||
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
||||
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||
@@ -68,11 +113,26 @@ ENV HOME=/home/agent
|
||||
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
|
||||
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir;
|
||||
# Antigravity nests its state inside `.gemini/antigravity-cli`, so it rides that seed.)
|
||||
# `.pi/agent` IS pre-created: pi is seeded per-FILE (auth/settings/trust/models), and a
|
||||
# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi:
|
||||
# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a
|
||||
# per-file seed copy, unlike a whole-dir one, does not create its parent directory.
|
||||
# `.dsh` is pre-created for the same per-file reason (.env/settings.yaml/
|
||||
# cordis.patch.yml), and the interactive profile is built into it HERE rather than
|
||||
# after `USER agent`: this layer's closing chgrp/chmod is what makes the whole tree
|
||||
# writable by the arbitrary uid the container actually runs as, and a profile
|
||||
# installed after it would miss that fixup. DSH_HOME points the launcher at the
|
||||
# agent's dir while this still runs as root.
|
||||
# `.omp/agent` is pre-created for the same reason `.codex` is: it is a MIXED
|
||||
# store (per-file config seeds PLUS a shared `sessions/` RW bind mount for
|
||||
# Codeman's own host-side history/resume reads), and neither kind of artifact
|
||||
# creates its own parent directory.
|
||||
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
||||
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent \
|
||||
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
|
||||
/home/agent/.dsh /home/agent/.omp/agent \
|
||||
&& DSH_HOME=/home/agent/.dsh HOME=/home/agent \
|
||||
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \
|
||||
&& test -f /home/agent/.dsh/profiles/dsh-tui/package.json \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -91,7 +91,7 @@ These map 1:1 to `CronJobSchema` (`src/web/schemas.ts`) and the `CronJob` type
|
||||
| Field | Required | Values / limits | Notes |
|
||||
| -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | ✅ | 1–200 chars | Display name; also used as the created session's name. |
|
||||
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` \| `pi` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. ⚠️ A `pi` job's readiness poll looks for `❯`/a token count, neither of which pi prints, so it burns the poll budget and then sends the prompt anyway (slower start, still works). |
|
||||
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` \| `pi` \| `grok` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. ⚠️ A `pi` or `grok` job's readiness poll looks for `❯`/a token count, which neither CLI prints, so it burns the poll budget and then sends the prompt anyway (slower start, still works). |
|
||||
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. |
|
||||
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
|
||||
| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. |
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# DeepSeek Harness (`dsh`) integration plan
|
||||
|
||||
> **Status**: Executed. This document records the plan, the decision behind each
|
||||
> wiring point, and what was and was not verified. The user-facing guide is
|
||||
> [`deepseek-integration.md`](./deepseek-integration.md); the per-decision
|
||||
> invariants live in
|
||||
> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek).
|
||||
> Template: the grok integration ([`grok-integration-plan.md`](./grok-integration-plan.md)),
|
||||
> itself calibrated against pi. Every fact below was measured against a live
|
||||
> **dsh 0.1.1-rc.2** install and **@deepseek-harness-tui/dsh-tui 0.9.0**, not read
|
||||
> from documentation.
|
||||
|
||||
## 1. What the DeepSeek Harness is
|
||||
|
||||
[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
|
||||
(open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills,
|
||||
sessions, sandboxes and whole APPS are Cordis plugins composed into *profiles*.
|
||||
`dsh` is the launcher — `dsh --profile <name>` boots
|
||||
`$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers under
|
||||
the user's own overrides. State lives in `~/.dsh` (`.env` 0600, `settings.yaml`,
|
||||
`cordis.patch.yml`, `profiles/`, `sessions/`, `storages/`).
|
||||
|
||||
## 2. Shape decisions (why DeepSeek is wired the way it is)
|
||||
|
||||
DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the
|
||||
browser UI is handled separately, §3). Three of its decisions have no precedent
|
||||
in the six external CLIs before it.
|
||||
|
||||
| Question | Decision | Why |
|
||||
| --- | --- | --- |
|
||||
| What does a pane run? | `dsh --profile <name>`, profile discovered | **The decision that shapes everything else.** DeepSeek ships `web`, `headless` and `base` — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. `resolveDefaultDeepSeekProfile()` prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses `web`/`headless`, which cannot occupy a pane. |
|
||||
| Which TUI? | none blessed; default for BOOTSTRAP only | `POST /api/deepseek/install-profile` defaults to `@deepseek-harness-tui/dsh-tui` (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. |
|
||||
| Permission bypass | `DSH_PERMISSION_MODE` env export, no flag | The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (`read-only` / `workspace-write` / `danger-full-access`, read off `dsh --dump-default-config`). This is the one legitimate exception to the `CLAUDE_CODE_EFFORT_LEVEL` ban: that var hard-locks in-session switching, whereas the harness reads this with `??` as a boot-time DEFAULT, so it stays soft. Exported via `tmux setenv`, never on the command line. The Run button sends `danger-full-access`, matching every sibling Run button. |
|
||||
| Multi-user clamp branch | only-if-sent, clamped to `workspace-write`, **plus an env-var half** | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. ⚠️ Unlike every sibling, clamping the CONFIG is only half the gate: the switch is an env var, `DSH_*` is an allowlisted `envOverrides` prefix, and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()`, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` on the same request would land last and win. `clampEnvOverridesForOwner()` drops `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to the clamped export). `DSH_HOME` because it aims the launcher at a profile tree whose plugin code runs at BOOT, before any approval row. |
|
||||
| `hooksAvailableForMode()` granularity | per SESSION for deepseek, per mode for everything else | `deepSeekConfig.statusReporting: false` disarms the `HERDR_*` export, and the triple is the only reason a dsh session posts anything, so a mode-only answer would accept `until=stop` where nothing can send one — the infinite-wait the predicate exists to prevent. Call sites pass `sessionHookOptions(session)`; the default stays permissive so a forgotten one degrades to the old behaviour. ⚠️ Profile conformance stays unknowable at request time (an unrecognized profile is deliberately launchable), so a non-conforming TUI still times out on an explicit `stop`; the default set keeps `idle`/`exit` for that. ⚠️ The predicate is NOT "is this claude": Read My Mind and intent capture read Claude's transcript and were silently widened by this change, so they compare `mode === 'claude'` directly now. |
|
||||
| Profile install spawn | own process group, hand-rolled timeout | `dsh plugin add` fans out into package-manager children, and spawn's built-in `timeout` signals only the direct child: survivors keep the inherited stdio pipes open, `close` never fires, and the held-open request leaks with no route-level deadline. `detached: true` + negative-pid SIGTERM→SIGKILL, the same escalation `runGit()` uses for the same reason, plus a last-resort reap for a grandchild that escaped the group. |
|
||||
| Idle detection | **real hook events via a status shim** | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` make it run `<bin> pane report-agent <id> --state idle\|working\|blocked …` on every state change, exit 0 = delivered. `deepseek-status-shim.ts` generates a script into the data dir and points `HERDR_BIN_PATH` at it. So deepseek is the only non-claude mode that passes `hooksAvailableForMode()` — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real `herdr` binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. |
|
||||
| `agent_working` event | new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next `stop` — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. |
|
||||
| Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) which would answer a version probe convincingly and then be handed a spawn line. `dsh --help` must match `DeepSeek Harness` first. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release. |
|
||||
| Env allowlist | `DSH_*` + `DEEPSEEK_*` | `DSH_*` covers the launcher's documented inputs (`DSH_HOME`, `DSH_PERMISSION_MODE`, `DSH_TELEMETRY_MODE`, the `DSH_TUI_*` knobs); `DEEPSEEK_*` is the vendor namespace holding `DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`, same reasoning that admitted `XAI_*` for grok. ⚠️ Pi's lesson repeats exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out. |
|
||||
| Model | NOT a session field | The model is a composition entry (`agent-default-model`) in the profile's config tree, set in `~/.dsh/settings.yaml` + `cordis.patch.yml`. Both create paths deliberately resolve no model for this mode rather than inventing a flag. |
|
||||
| Alt-screen strip | OUT of `isAltScreenStripMode()` | Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case. |
|
||||
| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports `@` completion and history search, which *may* make it per-keystroke reactive like codex; if so the fallback is the `'off'` branch. |
|
||||
| Docker | image installs dsh AND a profile | Profiles are deliberately NOT seeded from the host: each is a per-profile `node_modules` tree, host-arch-specific and far too large to copy per container start. Only `~/.dsh/.env`, `settings.yaml`, `cordis.patch.yml` are seeded (auth + model composition). The profile install rides the `useradd` layer so the closing `chgrp`/`chmod g=u` covers it, which is what keeps it usable under the arbitrary uid the container runs as. |
|
||||
| Remote SSH | `exec "$SHELL" -i -l -c 'dsh'` | Boots the remote box's default profile; a remote with several needs the per-host `commands.deepseek` override, since `deepSeekConfig` does not cross ssh. |
|
||||
|
||||
## 3. The web profile
|
||||
|
||||
The browser UI is the only interactive surface DeepSeek ships itself, so it gets
|
||||
a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts
|
||||
`dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-authority>`
|
||||
as a background process and opens the URL as an ordinary web tab.
|
||||
|
||||
The server was a **shell session** first, on the reasoning that Codeman already
|
||||
supervises those (visible, scrollable, killable, dies with its tab) so nothing
|
||||
new had to own a long-lived HTTP server. That version worked and was still
|
||||
wrong in use: clicking "open the DeepSeek web UI" put a terminal tab on screen
|
||||
next to the web tab actually asked for, every single time, and after the first
|
||||
launch the terminal was pure noise. Opening a dashboard should open one tab.
|
||||
|
||||
So `POST /api/deepseek/web` owns it instead (`src/deepseek-web-server.ts`), and
|
||||
what the session gave away for free is now explicit: exactly one server, reused
|
||||
rather than raced on a second click; restarted when the requested authority
|
||||
changes; killed on Codeman shutdown (a detached child would otherwise hold its
|
||||
port against the next start — the very EADDRINUSE this feature already got
|
||||
wrong once); and boot output captured, since with no shell tab there is nowhere
|
||||
else for a stack trace to land. It is fenced at the same bar as the profile
|
||||
installer: booting a dsh profile executes the plugin code in it, so it requires
|
||||
the privileged grant in multi-user mode.
|
||||
|
||||
`--trusted-host` is load-bearing — dsh fences its `/api` behind a browser-trust
|
||||
check on the request authority, and a Codeman web tab reaches it through
|
||||
Codeman's own origin via the webview proxy, not directly. The authority comes
|
||||
from the CLIENT (`location.host`) because only the browser knows which of a
|
||||
multi-homed Codeman's origins is actually in play.
|
||||
|
||||
Three things about this shortcut are load-bearing and each came from it failing
|
||||
in exactly that way against a real install:
|
||||
|
||||
- **The port is chosen, never hardcoded.** `GET /api/deepseek/web-port` walks
|
||||
3080..3119 for a free loopback port. 3080 is dsh's own default, which makes it
|
||||
precisely the port a DeepSeek user is most likely to already be serving on:
|
||||
binding it unconditionally killed the launch with `EADDRINUSE` against the
|
||||
user's own `dsh web`.
|
||||
- **The tab is opened only after the server answers.** The launch polls
|
||||
`POST /api/webviews/probe` until the URL responds, so a server that dies on
|
||||
startup reports the failure and points at its shell tab, instead of silently
|
||||
persisting a dashboard aimed at nothing.
|
||||
- **The saved tab is `trusted: true`, and must be.** An untrusted webview is
|
||||
sandboxed without `allow-same-origin`, which breaks this dashboard twice: the
|
||||
dsh client-runtime reads `localStorage` while loading plugins and dies there,
|
||||
and an opaque-origin frame sends `Origin: null`, so dsh's trust check 403s
|
||||
every `/api` call regardless of what `--trusted-host` names. Passing
|
||||
`location.host` only means anything once the frame actually carries that
|
||||
origin. The trade is real — a trusted proxied frame is same-origin with
|
||||
Codeman and can reach Codeman's API — and is defensible only because this
|
||||
particular dashboard is an agent harness Codeman just started itself on
|
||||
loopback, which can already run code as the user. It is not a precedent for
|
||||
trusting third-party dashboards generally.
|
||||
|
||||
The record is marked `managed: 'deepseek-web'`, which keeps it out of the
|
||||
saved-dashboard list: the shortcut that maintains it is already a menu entry, so
|
||||
listing both showed the same dashboard twice. Being managed is also what lets a
|
||||
relaunch repoint the existing row instead of stacking one dead dashboard per
|
||||
restart, since the port is now chosen per launch.
|
||||
|
||||
The authority baked into `--trusted-host` is the one the launch was clicked
|
||||
from, and reuse is conditional on it: a running server fenced for a *different*
|
||||
origin is stopped and restarted rather than reused, because reusing it renders a
|
||||
page whose every API call 403s — which reads as a broken dashboard rather than a
|
||||
misconfigured one.
|
||||
|
||||
## 4. Touch points (the checklist)
|
||||
|
||||
Backend: `types/session.ts` (SessionMode + `DeepSeekConfig` + SessionState),
|
||||
`utils/deepseek-cli-resolver.ts` (new) + barrel, `deepseek-status-shim.ts` (new),
|
||||
`tmux-manager.ts` (`buildDeepSeekCommand`, dispatch, resume flag, PATH export,
|
||||
truecolor, `_configureDeepSeek`, availability error, plumbing), `session.ts`
|
||||
(external-mode gate, label, config plumbing, tmux-required error, attach env),
|
||||
`mux-interface.ts`, `schemas.ts` (prefixes, `DeepSeekConfigSchema`,
|
||||
`DeepSeekInstallProfileSchema`, both mode enums, remote overrides, cron agentType,
|
||||
`agent_working`), `session-wait-registry.ts` (`hooksAvailableForMode`),
|
||||
`hook-event-routes.ts` (`APPROVAL_RESOLVING_EVENTS`), `session-routes.ts` (clamp +
|
||||
both create paths + `resolveDeepSeekLaunchError`), `system-routes.ts`
|
||||
(`GET /api/deepseek/status`, `POST /api/deepseek/install-profile`), `server.ts`
|
||||
(availability inject + mux restore), `sse-events.ts`, `docker-hosts.ts`,
|
||||
`remote-hosts.ts`, `config/dependency-registry.ts`,
|
||||
`response-viewer-transcript.ts`, `cron/cron-service.ts` (comment),
|
||||
`tui/tui-client.ts` + `tui-app.ts`.
|
||||
|
||||
Frontend: `index.html` (welcome button, run-mode entry, install affordance, web-UI
|
||||
shortcut, cron option, clone Brain option), `session-ui.js` (`runDeepSeek()`,
|
||||
`runDeepSeekWeb()`, `installDeepSeekProfile()`, dispatch, availability, "Run DS"
|
||||
label, external-CLI gates), `app.js` (label, `ds` tab badge, kill-menu, SSE map),
|
||||
`settings-ui.js` (welcome gate + `_onHookAgentWorking`), `constants.js`,
|
||||
`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`,
|
||||
`terminal-ui.js`, `styles.css` + `mobile.css` (brand-indigo identity; the non-og
|
||||
skin block and the mobile `!important` pair are both load-bearing).
|
||||
|
||||
Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword,
|
||||
`skills/codeman/reference/*`, CLAUDE.md, `architecture-invariants.md`.
|
||||
|
||||
Tests: `test/deepseek-mode.test.ts` + `test/deepseek-cli-resolver.test.ts` (new);
|
||||
`run-mode-ui`, `render-index-html`, `mobile-overview`, `agent-skill-mode-lists`
|
||||
(extended).
|
||||
|
||||
## 5. Verification performed
|
||||
|
||||
See the summary at the end of the implementing session for the live run. In
|
||||
short: the CI gate green; the resolver, profile inventory, spawn-line and clamp
|
||||
behaviour covered by 31 new unit tests; and an isolated instance used to exercise
|
||||
`GET /api/deepseek/status` and a real session against the live dsh install.
|
||||
|
||||
**Not verified (honest gaps):**
|
||||
|
||||
- The local-echo `'buffer'` policy against the TUI's real composer (§2). If it
|
||||
turns out per-keystroke reactive like codex's, flip it to the `'off'` branch;
|
||||
teaching `PredictiveEchoAddon` its composer row is the larger follow-up.
|
||||
- Scrollback/repaint behaviour of a third-party fullscreen TUI under the narrow
|
||||
strip during a long session.
|
||||
- A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image
|
||||
rebuild — see the `--no-cache` rule in CLAUDE.md).
|
||||
- A remote-SSH deepseek case.
|
||||
- The web-UI shortcut against a tunnel authority. Loopback and a tailnet name are
|
||||
both verified end to end through the webview proxy (dashboard renders, its
|
||||
`/api` calls succeed, no shell session created).
|
||||
|
||||
## 6. Follow-ups
|
||||
|
||||
- **Response viewer**: read `~/.dsh/sessions/**` (JSONL) the way codex rollouts
|
||||
are read back. Highest-value follow-up, and very achievable.
|
||||
- **`headless` as an execution backend** for Codeman's own AI checks
|
||||
(`ai-idle-checker`, `ai-plan-checker`), today Claude-only.
|
||||
- **Profile/model picker in Session Options**, reading `GET /api/deepseek/status`
|
||||
`.profiles`.
|
||||
- **`--patch` overlays per session**, which is the harness-native way to change
|
||||
agent composition without touching the user's profile.
|
||||
- Measure the local-echo policy and pin the result the way pi did.
|
||||
@@ -0,0 +1,297 @@
|
||||
# DeepSeek Harness (`dsh`) in Codeman
|
||||
|
||||
Codeman can run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
||||
as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
|
||||
Antigravity, Pi and Grok. It is the ninth run mode, and the one that is wired
|
||||
least like the others, for two reasons worth understanding before you use it.
|
||||
|
||||
## 1. The agent is a profile, not the binary
|
||||
|
||||
`dsh` is a **launcher**, not an agent. It boots a *profile*: an ordered stack of
|
||||
plugin-bundle patch layers under `$DSH_HOME/profiles/<name>` (`$DSH_HOME`
|
||||
defaults to `~/.dsh`). DeepSeek ships three bundles and none of them is a
|
||||
terminal agent:
|
||||
|
||||
| Profile | What it is | Can Codeman run it in a tab? |
|
||||
| ------------ | --------------------------------- | ---------------------------- |
|
||||
| `web` | the browser UI, served on :3080 | no — but see §6 |
|
||||
| `headless` | answers one task and exits | no |
|
||||
| (`base`) | the shared core, no app at all | no |
|
||||
|
||||
The interactive terminal front door is **always a third-party plugin**. So
|
||||
"DeepSeek is installed" and "Codeman can start a DeepSeek session" are different
|
||||
questions, and Codeman answers both separately:
|
||||
|
||||
```bash
|
||||
curl -s localhost:3000/api/deepseek/status | jq
|
||||
{
|
||||
"available": true, # the `dsh` binary resolved and proved its identity
|
||||
"runnable": false, # ...but nothing installed can drive a pane
|
||||
"path": "/home/you/.local/bin",
|
||||
"version": "0.1.1-rc.2",
|
||||
"dshHome": "/home/you/.dsh",
|
||||
"defaultProfile": null,
|
||||
"profiles": [ { "name": "web", "kind": "web", "bundles": [...] } ]
|
||||
}
|
||||
```
|
||||
|
||||
### Installing a terminal profile
|
||||
|
||||
From the UI: open the **Run** dropdown. When `dsh` is installed but no
|
||||
pane-capable profile is, the menu shows **DeepSeek — add a terminal profile…**.
|
||||
One click installs one and the normal DeepSeek entry appears.
|
||||
|
||||
By hand, or to pick a different front door:
|
||||
|
||||
```bash
|
||||
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui
|
||||
```
|
||||
|
||||
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||
margin the most used community TUI, it is MIT, and it implements the status
|
||||
contract described in §3. It is a **default, not a requirement**: any profile
|
||||
under `$DSH_HOME/profiles` that is not `web` or `headless` shows up in the
|
||||
inventory and can be launched, including one you compose yourself. The endpoint
|
||||
accepts any npm package name:
|
||||
|
||||
```bash
|
||||
curl -sX POST localhost:3000/api/deepseek/install-profile \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"profile":"my-tui","package":"@someone/dsh-tui"}'
|
||||
```
|
||||
|
||||
Installing a plugin is arbitrary code execution on the host, so in multi-user
|
||||
mode this endpoint requires the can-bypass-permissions grant (the same bar as a
|
||||
`shell` session). The request is held open while the package manager runs and is
|
||||
bounded at five minutes; the install runs in its own process group, so hitting
|
||||
that bound kills the whole tree rather than just the launcher.
|
||||
|
||||
> **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's
|
||||
> shell", a distributed shell, which would answer `--version` convincingly.
|
||||
> Codeman's resolver therefore demands the harness's own help banner before it
|
||||
> will point a spawn line at a candidate, and `GET /api/deepseek/status` reports
|
||||
> `path` and `version` so a misresolution is diagnosable rather than presenting
|
||||
> as "the mode just doesn't work".
|
||||
|
||||
## 2. Permissions are an env var, not a flag
|
||||
|
||||
The harness has **no `--dangerously-skip-permissions` equivalent**. Its sandbox
|
||||
and approval rows are configuration, driven by one documented input,
|
||||
`DSH_PERMISSION_MODE`, with three presets (read off `dsh --dump-default-config`):
|
||||
|
||||
| `DSH_PERMISSION_MODE` | sandbox | approvals | notes |
|
||||
| --------------------- | -------------------- | --------- | ------------------------- |
|
||||
| `read-only` | `read-only` | ask | |
|
||||
| `workspace-write` | `workspace-write` | ask | the harness's own default |
|
||||
| `danger-full-access` | `danger-full-access` | **never** | what the Run button sends |
|
||||
|
||||
Codeman exports it via `tmux setenv`, never on the command line. Because the
|
||||
harness reads it with `??`, it is a **soft default**: it sets the boot-time
|
||||
preset and you can still change permission mode inside the session.
|
||||
|
||||
Omitting it entirely leaves the harness on `workspace-write`, which still asks —
|
||||
which is why the multi-user clamp only needs to force a *sent* value down. A
|
||||
non-granted owner's `danger-full-access` becomes `workspace-write`, not
|
||||
`read-only`: the clamp removes privilege without breaking the session's ability
|
||||
to edit its own workspace.
|
||||
|
||||
Because the switch is an env var rather than a flag, that clamp has a second half
|
||||
no other CLI needs. `DSH_*` is an allowlisted `envOverrides` prefix (it has to be:
|
||||
that is also how you set the harness's ordinary knobs), and env overrides are
|
||||
applied *after* the permission export, so in multi-user mode a non-granted owner
|
||||
sending
|
||||
|
||||
```json
|
||||
{ "mode": "deepseek", "envOverrides": { "DSH_PERMISSION_MODE": "danger-full-access" } }
|
||||
```
|
||||
|
||||
would otherwise hand back the privilege the config clamp just removed. For a
|
||||
non-granted owner Codeman therefore **drops `DSH_PERMISSION_MODE` and `DSH_HOME`
|
||||
from `envOverrides`**; dropping them falls through to the clamped config and the
|
||||
server's own `DSH_HOME`. `DSH_HOME` is in that list because it points the
|
||||
launcher at a profile tree, and a profile's plugin code runs at boot, before any
|
||||
approval row can apply. Single-user installs and granted owners are unaffected.
|
||||
|
||||
## 3. Real idle detection (the interesting part)
|
||||
|
||||
Every other external CLI mode in Codeman is **readiness-guessed**: Codeman
|
||||
watches the PTY go quiet and infers that a turn ended. Claude is the exception,
|
||||
because Claude Code fires hooks.
|
||||
|
||||
DeepSeek is the second exception. The community terminal front door already
|
||||
reports its own lifecycle to a supervising process through a generic,
|
||||
env-var-gated contract (inherited from [Herdr](https://herdr.dev)): when
|
||||
`HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, it shells out on
|
||||
every state change with
|
||||
|
||||
```
|
||||
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
|
||||
--source custom:dsh-tui --agent dsh-tui \
|
||||
--state idle|working|blocked [--message ...] --seq N
|
||||
```
|
||||
|
||||
Codeman points `HERDR_BIN_PATH` at a small generated shim
|
||||
(`~/.codeman/dsh-status-shim.mjs`, written at session create) which forwards each
|
||||
report to `POST /api/hook-event`. The mapping:
|
||||
|
||||
| Harness state | Codeman hook event | What you get |
|
||||
| ------------- | ------------------ | -------------------------------------------------------- |
|
||||
| `blocked` | `permission_prompt`| red "needs you" tab alert + an Approvals Inbox item |
|
||||
| `idle` | `stop` | definitive end-of-turn: respawn triggers, `wait` returns |
|
||||
| `working` | `agent_working` | clears an alert answered in the terminal, at once |
|
||||
|
||||
So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait`
|
||||
really can block on `stop` and `blocked` for it, and it is the only non-Claude
|
||||
mode for which that is true (`hooksAvailableForMode`).
|
||||
|
||||
That is a per-*session* answer, not a per-mode one. Turning the bridge off with
|
||||
`deepSeekConfig.statusReporting: false` means nothing will ever post a hook event
|
||||
for that session, so an explicit `until=stop` is refused up front (with a message
|
||||
naming the setting) rather than blocking for your whole timeout. Omitting `until`
|
||||
never fails: the hook-only signals are dropped from the default set and you still
|
||||
get `idle` and `exit`.
|
||||
|
||||
One limit worth knowing: whether the *profile* implements the contract cannot be
|
||||
known at request time (Codeman deliberately treats an unrecognized profile as
|
||||
launchable). A dsh session running a non-conforming TUI therefore still accepts
|
||||
`until=stop` and will time out on it. `idle`/`exit` are the reliable pair there.
|
||||
|
||||
This is an interface implementation, not an impersonation — nothing on your
|
||||
machine executes a real `herdr` binary. If you use a terminal profile that does
|
||||
*not* implement the contract, the shim is simply never called and the mode falls
|
||||
back to output-stabilization readiness like its siblings. Turn it off per session
|
||||
with `deepSeekConfig.statusReporting: false`.
|
||||
|
||||
## 4. Starting a session
|
||||
|
||||
From the UI, pick **DeepSeek** in the Run dropdown (or the **Run DeepSeek**
|
||||
welcome button) and press Run. Over the API:
|
||||
|
||||
```bash
|
||||
curl -sX POST localhost:3000/api/quick-start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"caseName": "myproject",
|
||||
"mode": "deepseek",
|
||||
"deepSeekConfig": {
|
||||
"profile": "dsh-tui",
|
||||
"permissionMode": "danger-full-access"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
`deepSeekConfig` fields: `profile`, `permissionMode`, `resumeSession`,
|
||||
`resumeSessionId`, `statusReporting`. Resume prefers an explicit id over the
|
||||
most-recent form, and both are passed through to the profile's app, which is
|
||||
where `--resume` is understood.
|
||||
|
||||
**Models are not a session field.** The model is a composition entry in the
|
||||
profile's config tree (`agent-default-model`), not a CLI flag, so Codeman does
|
||||
not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
||||
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||
profile. That is also how you point dsh at a local or third-party provider.
|
||||
|
||||
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||
`settings.yaml` can nominate any env var as a credential via `apiKeyEnv`, and
|
||||
Codeman's allowlist is global, so admitting them would widen it for every mode at
|
||||
once. Authenticate those the way dsh does, from the file or the server's own
|
||||
environment.
|
||||
|
||||
## 5. Reading a session back, and driving one as a worker
|
||||
|
||||
dsh writes a real transcript — `$DSH_HOME/sessions/<mangled-cwd>/<id>/session.jsonl.zstd`
|
||||
— so `GET /api/sessions/:id/last-response` reads that rather than segmenting the
|
||||
pane, and the Response Viewer shows a dsh conversation the way it shows a claude
|
||||
or codex one (`?context=full` returns prompt / response / tool blocks).
|
||||
|
||||
Reading the pane instead is not merely coarse for this mode, it is wrong: dsh-TUI
|
||||
paints a full-screen splash, so the segmenter answered a `last-response` call for
|
||||
a fresh dsh session with its ASCII-art logo — which anything polling for a
|
||||
worker's first answer reads as an answer. Three things about the file shaped the
|
||||
reader (`src/deepseek-transcript.ts`):
|
||||
|
||||
- **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all
|
||||
of them, Node's `zlib` zstd decoder stops at the first: a real 56-line
|
||||
transcript came back as 1 line. The reader walks frame headers itself. On a Node
|
||||
older than 22.15 (no zstd at all) the mode falls back to the pane, as before.
|
||||
- **Not every `user/message` is the user.** Each turn also records a
|
||||
plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a
|
||||
prompt.
|
||||
- **A failed turn is not an empty one.** `turn/end` carries the provider's error,
|
||||
which is returned as `Turn error: …` (and an early stop such as `max-tokens` as
|
||||
`Turn ended: …`) instead of an empty string that reads as "still thinking".
|
||||
|
||||
The transcript reader applies to **local** dsh sessions only. A Docker case's
|
||||
harness writes its transcript inside the container's own `~/.dsh` (the workspace
|
||||
bind mount does not cover it), and a remote-SSH case's lives on the remote host,
|
||||
so the local reader could never find those files — such sessions keep the pane
|
||||
segmenter, coarse but real. The splash caveat above applies to them accordingly.
|
||||
|
||||
### As an agent worker
|
||||
|
||||
Because dsh has both halves — a real end-of-turn signal and a real transcript — an
|
||||
agent can drive a dsh session the same way it drives a claude one, and the bundled
|
||||
`codeman` agent skill does. Spawning `beta:deepseek` in its worker list gives a
|
||||
worker that is tasked, waited on and read with the same calls as its claude
|
||||
siblings; no other external CLI mode qualifies. Two edges are worth repeating here:
|
||||
|
||||
- **Readiness is not the stop signal.** The harness reports `idle` at boot roughly
|
||||
300 ms *before* the composer paints (measured 2.26 s vs 2.56 s after spawn), so a
|
||||
send-and-wait fired immediately after create resolves on that boot report,
|
||||
reports a turn that never ran, and leaves the prompt in a pane that was not yet
|
||||
accepting input. Wait for the composer (`❯`) instead.
|
||||
- **Wait on `stop`, not on the default signal set.** That set also carries `idle`,
|
||||
which for every external CLI is inferred from output stabilization; a dsh TUI
|
||||
that repaints rarely reads as idle mid-turn.
|
||||
|
||||
## 6. The web UI as a tab
|
||||
|
||||
The browser UI is the one interactive surface DeepSeek ships itself, so it gets a
|
||||
shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts
|
||||
`dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-host>`
|
||||
as a background child process (`src/deepseek-web-server.ts`, behind
|
||||
`POST/GET/DELETE /api/deepseek/web`) and opens it as a Codeman web tab once the
|
||||
server actually answers.
|
||||
|
||||
It is a child process rather than a shell session because the session version
|
||||
opened a terminal tab nobody asked for on every click. What the session gave for
|
||||
free is therefore explicit here: one instance with reuse, a restart when the
|
||||
requested `--trusted-host` authority differs from the running one, a kill on
|
||||
server stop, and captured boot output. The `--trusted-host` flag is load-bearing —
|
||||
dsh fences its `/api` behind a browser-trust check on the request authority, and a
|
||||
Codeman web tab reaches it through Codeman's own origin via the webview proxy, not
|
||||
directly. Without it the page renders and every API call fails.
|
||||
|
||||
## 7. Docker and remote cases
|
||||
|
||||
Docker cases work: the agent image installs `dsh` and bootstraps a `dsh-tui`
|
||||
profile into the container. Profiles are deliberately **not** seeded from the
|
||||
host (each is a per-profile `node_modules` tree, host-arch-specific and far too
|
||||
large to copy on every container start); only `~/.dsh/.env`, `settings.yaml` and
|
||||
`cordis.patch.yml` are seeded, which is what carries auth and model composition
|
||||
in. As with pi and grok, in-container sessions are invisible host-side:
|
||||
`~/.dsh/sessions` inside a container is that container's own.
|
||||
|
||||
Remote SSH cases default to `dsh` through a login shell, which boots the remote
|
||||
box's default profile. If the remote has several, name one with the per-host
|
||||
`commands.deepseek` override — the local `deepSeekConfig` does not cross ssh.
|
||||
|
||||
## 8. What is not wired
|
||||
|
||||
Deliberately minimal, on the same reasoning as the grok integration: the harness
|
||||
is a fast-moving developer preview and every flag added is a flag validated
|
||||
forever.
|
||||
|
||||
- `--patch` overlays per session (the profile's own layers apply as normal).
|
||||
- `dsh plugin` management beyond first-time profile install.
|
||||
- The `headless` profile as a one-shot execution backend for Codeman's own
|
||||
internal AI checks (today those are Claude-only).
|
||||
- Model/provider selection from Session Options.
|
||||
|
||||
## Verified against
|
||||
|
||||
`dsh 0.1.1-rc.2` and `@deepseek-harness-tui/dsh-tui 0.9.0`. The permission
|
||||
presets, the profile layout, and the supervisor contract above were all read off
|
||||
the live install rather than from documentation.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
|
||||
|
||||
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` all work inside the container.
|
||||
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` all work inside the container.
|
||||
|
||||
## One-time setup: build the base image
|
||||
|
||||
@@ -25,12 +25,12 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
|
||||
|
||||
```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; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
|
||||
```
|
||||
|
||||
Antigravity (`agy`) is the one CLI not installed from npm (Google ships a standalone binary), so it has its own Dockerfile step and adds roughly 190MB; a full image lands near 1.6GB. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other four npm CLIs install.
|
||||
Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (Google and xAI ship standalone binaries), so each has its own Dockerfile step, adding roughly 190MB and 160MB respectively. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other npm CLIs install.
|
||||
|
||||
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md).
|
||||
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Grok Build (xAI) integration plan
|
||||
|
||||
> **Status**: Executed. This document records the plan, the decision behind each wiring
|
||||
> point, and what was and was not verified. The user-facing guide is
|
||||
> [`grok-integration.md`](./grok-integration.md); the per-decision invariants live in
|
||||
> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok).
|
||||
> Template: the pi integration (`c5b5963`, [`pi-integration-plan.md`](./pi-integration-plan.md)),
|
||||
> which was itself calibrated against the four follow-up commits the antigravity
|
||||
> integration needed. All of grok's facts below were verified against **grok 1.0.5**
|
||||
> (`grok 1.0.5 (5115b46bc9)`), installed live during the work.
|
||||
|
||||
## 1. What Grok Build is
|
||||
|
||||
[xai-org/grok-build](https://github.com/xai-org/grok-build) is xAI's coding agent: a
|
||||
Rust fullscreen-TUI binary named `grok`, installed by
|
||||
`curl -fsSL https://x.ai/cli/install.sh | bash` into `~/.grok/bin` (with symlinks into
|
||||
`~/.local/bin`; the installer also ships an `agent` alias). Config lives in
|
||||
`~/.grok/config.toml`, TUI appearance in `~/.grok/pager.toml`, credentials in
|
||||
`~/.grok/auth.json` (0600), sessions under `~/.grok/sessions/`. Auth is browser OAuth
|
||||
on first launch, `grok login --device-auth` for SSH boxes, or `XAI_API_KEY` for
|
||||
headless use. It has Claude-style permission modes (`default`/`acceptEdits`/`auto`/
|
||||
`dontAsk`/`bypassPermissions`/`plan`), allow/deny rules, hooks, MCP, subagents, and a
|
||||
headless `-p` mode.
|
||||
|
||||
## 2. Shape decisions (why grok is wired the way it is)
|
||||
|
||||
Grok is a seventh run mode, alongside Claude Code, shell, OpenCode, Codex, Gemini,
|
||||
Antigravity and Pi. Never a location overlay, never a web tab. Its wiring mixes two
|
||||
existing shapes:
|
||||
|
||||
| Question | Decision | Why |
|
||||
| --- | --- | --- |
|
||||
| Permission bypass | `GrokConfig.alwaysApprove` -> `--always-approve` | Grok's real flag (verified via `--help`): "Auto-approve all tool executions", i.e. its `bypassPermissions` mode. Config-level deny rules still apply on top. The Run button sends `true`, matching `runAntigravity()` and Claude's own `--dangerously-skip-permissions` default: Codeman sessions exist for autonomous work. |
|
||||
| Multi-user clamp branch | only-if-sent (codex/antigravity branch) | A bare `grok` spawn is grok's own ask-mode default, which is already safe, so the clamp only needs to force a SENT `alwaysApprove` off. Contrast pi, whose absent default is an answerable prompt and therefore needs the materialize branch. Cron needs nothing for grok for the same reason (`clampCronExternalCliConfigs`). |
|
||||
| Alt-screen strip | OUT of `isAltScreenStripMode()` | Grok is a fullscreen alternate-screen TUI with mouse support (its own scrollback pane, `pager.toml [terminal] alt_screen`), i.e. the opencode case, not the Ink repaint case. It falls through to the narrow tmux-attach strip like opencode/antigravity/pi. |
|
||||
| Resolver | version probe, like pi | `grok` has npm squatters (the unrelated `@vibe-kit/grok-cli` installs a `grok` bin). Candidates must pass `grok --version`; `GROK_VERSION_REGEX` is exported and shared with the dependency registry so doctor and run mode cannot disagree. The probe cannot tell two version-printing `grok`s apart, so `GET /api/grok/status` surfaces path AND version. Search dirs: `~/.grok/bin` first (installer target), then `~/.local/bin`, `/usr/local/bin`, `~/bin`. |
|
||||
| Env allowlist | `GROK_*` + `XAI_*` prefixes | `GROK_*` covers grok's documented inputs (`GROK_HOME`, `GROK_CONFIG`/`GROK_CONFIG_PATH`, `GROK_MEMORY`, `GROK_WORKFLOWS`, `GROK_SANDBOX`, `GROK_OIDC_*`, `GROK_AUTH_PROVIDER_COMMAND`). `XAI_*` is xAI's vendor namespace and carries `XAI_API_KEY`, grok's documented headless auth var: the same narrow-vendor-namespace reasoning that admitted `GOOGLE_*` for gemini. Foreign provider keys stay out, as always. |
|
||||
| Resume | `--resume <id>` / `--continue`, id-regexed | Grok's `--resume` also matches session TITLES (arbitrary user strings, case-insensitive). The `^[a-zA-Z0-9._-]+$` regex doubles as the no-titles rule, so nothing free-form can reach the `bash -c` spawn line. A valid explicit id wins over `-c`, mirroring pi. |
|
||||
| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against an authenticated session (see §4). If grok's composer turns out per-keystroke reactive like codex's, the fallback is one `'off'` branch; teaching `PredictiveEchoAddon` grok's composer row is the larger follow-up. |
|
||||
| Truecolor | `COLORTERM=truecolor` + `unset NO_COLOR` | Rust TUI with themes; joins the codex/gemini/antigravity/pi list in `buildEnvExports()` and `buildMuxAttachEnv()`. |
|
||||
| Docker credentials | per-file seed: `auth.json`, `config.toml`, `pager.toml` | `~/.grok` also holds `sessions/`, `memory/`, `completions/`, `docs/` and the ~160MB binary under `downloads/`; a whole-dir seed would copy all of it on every container start. Same trade-off as pi: in-container sessions are invisible host-side, so `grok -c` in a Docker case sees only that container's history. |
|
||||
| Docker install | own Dockerfile step | Not an npm package. xAI's installer has no `--dir` override, so the step copies `/root/.grok/bin/grok` (through the symlink, `cp -L`) into `/usr/local/bin` and removes root's `~/.grok` in the same layer. |
|
||||
| Remote SSH | `exec "$SHELL" -i -l -c 'grok'` | sshd's remote-command PATH does not include `~/.grok/bin`; same login-shell fix as every other agent CLI. |
|
||||
| What is NOT wired | `--permission-mode`, `--allow`/`--deny`, `-p` headless, `--worktree`, `--sandbox`, `--reasoning-effort`, `-s/--session-id`, `--fork-session`, `--agent`, `--output-format` | Follow-ups. The flag surface is kept minimal on purpose; grok is pre-1.0-style fast-moving and every flag added is a flag validated forever. |
|
||||
|
||||
## 3. Touch points (the checklist)
|
||||
|
||||
Backend: `types/session.ts` (SessionMode + GrokConfig + SessionState), `utils/grok-cli-resolver.ts` (new)
|
||||
+ barrel, `tmux-manager.ts` (`buildGrokCommand`, dispatch, resume flag, PATH export, truecolor,
|
||||
availability error, plumbing), `session.ts` (external-mode gate, label, config plumbing,
|
||||
tmux-required error, attach env), `mux-interface.ts`, `schemas.ts` (prefixes, `GrokConfigSchema`,
|
||||
both mode enums, remote command overrides, cron agentType), `session-routes.ts` (clamp + both
|
||||
create paths), `system-routes.ts` (`GET /api/grok/status`), `server.ts` (availability inject +
|
||||
mux restore), `docker-hosts.ts`, `remote-hosts.ts`, `config/dependency-registry.ts`,
|
||||
`cron/cron-service.ts` (comment), `response-viewer-transcript.ts`, `tui/tui-client.ts` + `tui-app.ts`.
|
||||
|
||||
Frontend: `index.html` (welcome button, run-mode entry, cron option, clone Brain option),
|
||||
`session-ui.js` (`runGrok()`, dispatch, availability, "Run GK" label, external-CLI gates,
|
||||
runMode setter), `app.js` (label, `gk` tab badge, kill-menu), `settings-ui.js`,
|
||||
`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`, `styles.css` +
|
||||
`mobile.css` (charcoal monochrome identity; the non-og skin block and the mobile
|
||||
`!important` pair are both load-bearing, see the pi plan's §2.9 cascade trap).
|
||||
|
||||
Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword, changeset,
|
||||
`skills/codeman/reference/*`, CLAUDE.md, READMEs, `architecture-invariants.md`,
|
||||
`remote-sessions.md`, `security-architecture.md`, `docker-cases.md`, `cron-guide.md`.
|
||||
|
||||
Tests: `test/grok-mode.test.ts` + `test/grok-cli-resolver.test.ts` (new);
|
||||
`external-cli-bypass-clamp`, `system-routes`, `render-index-html`, `run-mode-ui`,
|
||||
`mobile-overview`, `local-echo-codex-gating` (extended).
|
||||
|
||||
## 4. Verification performed
|
||||
|
||||
On this box, with grok 1.0.5 really installed and an isolated
|
||||
`CODEMAN_INSTANCE=grokwt` server (own data dir, own tmux socket, port 5077):
|
||||
|
||||
1. `npm test` (the CI gate): green, 5900+ tests. `typecheck`, `lint`, `format:check`,
|
||||
`check:frontend-syntax`, `check:public-assets`, `check:lockfile`: green.
|
||||
2. `GET /api/grok/status` -> `{available: true, path: "/home/arkon/.local/bin", version: "1.0.5"}`
|
||||
through the real resolver and probe.
|
||||
3. `POST /api/quick-start {mode: "grok", grokConfig: {alwaysApprove: true}}` -> session
|
||||
created, tmux pane spawned, real spawn line verified to end in `grok --always-approve`,
|
||||
and the actual grok TUI rendered its OAuth device-approval screen in the pane
|
||||
(unauthenticated box, so sign-in is exactly where a first run lands).
|
||||
4. `grokConfig` persisted into the instance's `state.json`.
|
||||
5. Session deleted by exact id; instance data dir and throwaway case removed.
|
||||
|
||||
**Not verified (honest gaps, all requiring an xAI account or more hardware):**
|
||||
an authenticated conversation end to end; the local-echo buffer policy against grok's
|
||||
real composer (§2); scrollback/repaint behavior of the fullscreen TUI under the narrow
|
||||
strip during a long session; a Docker case with `mode: 'grok'` (needs a `--no-cache`
|
||||
agent-image rebuild); a remote-SSH grok case; cron readiness degradation (expected:
|
||||
same slow-start-then-send as pi, documented in `cron-guide.md`).
|
||||
|
||||
## 5. Follow-ups
|
||||
|
||||
- Idle/completion signal: grok has a hooks system (user-guide `10-hooks.md`); a hook
|
||||
POSTing to `/api/hook-event` could give grok sessions real idle detection instead of
|
||||
output-stabilization. Highest-value follow-up, same slot as pi's `agent_settled` idea.
|
||||
- Response viewer: sessions are ACP JSONL under `~/.grok/sessions/<encoded-cwd>/<id>/updates.jsonl`;
|
||||
`grok -p ... --output-format json | jq -r '.sessionId'` exists for correlation.
|
||||
- Permission-mode picker (`--permission-mode`, `--allow`/`--deny`) in Session Options.
|
||||
- Measure the local-echo policy and the fullscreen-TUI scrollback behavior against an
|
||||
authenticated session; pin the result in `local-echo-codex-gating` the way pi did.
|
||||
- `grok doctor` is a built-in terminal-support check worth pointing users at when a
|
||||
pane renders oddly.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Grok Build (xAI) sessions
|
||||
|
||||
Codeman can drive [Grok Build](https://github.com/xai-org/grok-build) (xAI's `grok`
|
||||
CLI, the agent behind docs.x.ai/build) as a session backend, alongside Claude Code,
|
||||
OpenCode, Codex, Gemini, Antigravity and Pi. `grok` is a seventh **run mode**: its own
|
||||
PTY, its own tmux session, its own tab identity (monochrome charcoal, `gk` badge). It
|
||||
is not a location overlay like Docker or remote-SSH cases, and it is not a web tab.
|
||||
|
||||
The design rationale behind each decision below lives in
|
||||
[`grok-integration-plan.md`](./grok-integration-plan.md). Everything here was verified
|
||||
against grok 1.0.5.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://x.ai/cli/install.sh | bash
|
||||
```
|
||||
|
||||
The installer places the binary in `~/.grok/bin` and symlinks it into `~/.local/bin`
|
||||
(it also installs an `agent` alias Codeman ignores). `grok update` self-updates.
|
||||
|
||||
Codeman resolves the binary via the server PATH and then the usual install locations,
|
||||
`~/.grok/bin` first. **`grok` is a name with known squatters** (the unrelated
|
||||
`@vibe-kit/grok-cli` npm package also installs a `grok` bin), so like `pi` the
|
||||
resolver does not trust a PATH hit on its own: it runs `grok --version` once and
|
||||
requires version-shaped output (`grok 1.0.5 (5115b46bc9)`). Check what it resolved:
|
||||
|
||||
```bash
|
||||
curl -s localhost:3000/api/grok/status | jq
|
||||
# { "available": true, "path": "/home/you/.grok/bin", "version": "1.0.5" }
|
||||
```
|
||||
|
||||
The endpoint carries `version` on top of the sibling `/api/*/status` shape precisely
|
||||
so a misresolution is visible rather than presenting as "the mode just doesn't work".
|
||||
|
||||
## Authenticate
|
||||
|
||||
- **Browser OAuth (default)**: the first `grok` run opens a sign-in flow; in a
|
||||
Codeman pane you get the device-code screen with a URL to open elsewhere.
|
||||
Credentials land in `~/.grok/auth.json` (0600) and refresh automatically.
|
||||
- **Device code**: `grok login --device-auth`, made for SSH boxes and headless hosts.
|
||||
- **API key**: `export XAI_API_KEY="xai-..."` (console.x.ai). Used as a fallback when
|
||||
no session token exists. As a per-session Codeman `envOverride` it flows through
|
||||
socket-scoped `tmux setenv`, never the spawn command line.
|
||||
- **Enterprise OIDC**: `GROK_OIDC_ISSUER` / `GROK_OIDC_CLIENT_ID`.
|
||||
|
||||
## What Codeman wires up
|
||||
|
||||
`GrokConfig` (per session, persisted in `state.json`, round-trips through respawn):
|
||||
|
||||
| Field | Flag | Notes |
|
||||
| ----------------- | --------------------------- | --------------------------------------------------------------------- |
|
||||
| `model` | `--model <v>` | e.g. `grok-4.5`, or a custom `[model.<name>]` from `config.toml` |
|
||||
| `alwaysApprove` | `--always-approve` | Grok's `bypassPermissions` mode; deny rules still apply on top |
|
||||
| `continueSession` | `--continue` | Most recent session for the working directory; skipped when resuming |
|
||||
| `resumeSessionId` | `--resume <v>` | Ids only, never titles (grok's own `--resume` also matches titles) |
|
||||
|
||||
Every value is regex-validated and **dropped** (not escaped) if it fails, because the
|
||||
result is interpolated into the pane's `bash -c "..."` command.
|
||||
|
||||
The Run button sends `grokConfig: { alwaysApprove: true }`, the same product decision
|
||||
as Claude's `--dangerously-skip-permissions` default and Antigravity's
|
||||
`--dangerously-skip-permissions`: Codeman sessions exist for autonomous work. Keep
|
||||
hard limits as `deny` rules in `~/.grok/config.toml` (they apply in every mode), and
|
||||
in **multi-user mode** a non-granted owner's `alwaysApprove` is forced off
|
||||
server-side; a bare `grok` spawn is grok's own ask-mode default.
|
||||
|
||||
Env overrides: the `GROK_*` prefix (`GROK_HOME`, `GROK_CONFIG`, `GROK_MEMORY`,
|
||||
`GROK_WORKFLOWS`, `GROK_SANDBOX`, `GROK_OIDC_*`, ...) plus the `XAI_*` vendor
|
||||
namespace (`XAI_API_KEY`) are allowlisted. Foreign provider keys are not, as ever.
|
||||
|
||||
## What Codeman deliberately does NOT wire up
|
||||
|
||||
- **`--permission-mode`, `--allow`/`--deny`.** The boolean covers the autonomous
|
||||
case; the full rule surface is a follow-up with UI.
|
||||
- **`-p`/headless, `--output-format`, `--json-schema`.** Codeman drives the TUI.
|
||||
- **`--worktree`, `--sandbox`, `--reasoning-effort`, `-s/--session-id`,
|
||||
`--fork-session`, `--agent`/`--agents`.** Tracked as follow-ups in the plan doc.
|
||||
|
||||
## Terminal behavior
|
||||
|
||||
Grok renders a **fullscreen alternate-screen TUI** (scrollback pane + prompt, mouse
|
||||
supported). Under Codeman it runs inside tmux like every external CLI, so the
|
||||
fullscreen rendering stays inside the pane and the browser terminal shows tmux's
|
||||
repaints; grok stays out of the alt-screen strip list on purpose (the opencode case,
|
||||
not the Ink case). If a pane renders oddly, `grok doctor` checks terminal, color and
|
||||
input support without starting a session, and `~/.grok/pager.toml` can force
|
||||
`alt_screen = "inline"`.
|
||||
|
||||
On touch devices grok currently gets the buffered local-echo overlay like Claude,
|
||||
Gemini, OpenCode and Pi. This is the fallthrough default and has not been measured
|
||||
against an authenticated grok composer; if grok turns out per-keystroke reactive the
|
||||
way codex was (issues #218/#219/#220/#222), the fix is the `'off'` branch in
|
||||
`_updateLocalEchoState` (terminal-ui.js).
|
||||
|
||||
## Docker cases
|
||||
|
||||
The agent image installs grok in its own Dockerfile step (not npm; xAI's installer
|
||||
targets `$HOME/.grok/bin` with no `--dir` override, so the binary is copied to
|
||||
`/usr/local/bin`). Rebuild with the mandatory `--no-cache`:
|
||||
|
||||
```bash
|
||||
node scripts/build-agent-image.mjs --no-cache
|
||||
```
|
||||
|
||||
Credentials are **seeded**, not shared: `auth.json`, `config.toml` and `pager.toml`
|
||||
are copied into the container's own `~/.grok`, so an in-container grok never writes
|
||||
refreshed OAuth tokens back to the host and `docker commit` exports stay secret-free.
|
||||
Only those three files, because `~/.grok` also holds `sessions/`, `memory/` and the
|
||||
~160MB binary under `downloads/`. Trade-off, same as pi: in-container sessions are
|
||||
invisible host-side, so `grok -c` inside a Docker case only sees that container's own
|
||||
history.
|
||||
|
||||
## Remote SSH cases
|
||||
|
||||
`grok` mode is routed through an interactive login shell
|
||||
(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include
|
||||
`~/.grok/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected
|
||||
rather than silently ignored; use the per-host command override instead. For auth on
|
||||
the remote host, `grok login --device-auth` exists for exactly this.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook yet.** Idle detection falls back to output-stabilization
|
||||
like the other external CLIs. Grok has a hooks system, so a Codeman hook POSTing to
|
||||
`/api/hook-event` is the highest-value follow-up.
|
||||
- **No response viewer.** Grok writes ACP JSONL sessions under
|
||||
`~/.grok/sessions/<encoded-cwd>/<session-id>/updates.jsonl`; nothing reads them yet.
|
||||
- **Cron jobs mis-detect readiness.** The readiness poll looks for `❯` or a token
|
||||
count, neither of which grok prints, so a grok cron job burns its poll budget and
|
||||
then sends the prompt anyway. It works; it is just slower to start.
|
||||
- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are
|
||||
off** for grok, as for every external CLI.
|
||||
@@ -0,0 +1,171 @@
|
||||
# OMP (Oh My Pi) sessions
|
||||
|
||||
Codeman can drive [OMP](https://github.com/can1357/oh-my-pi) (`omp`, Oh My Pi) as a session
|
||||
backend, alongside Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok and
|
||||
DeepSeek Harness. `omp` is the ninth CLI backend (tenth `SessionMode`, counting
|
||||
`shell`): its own PTY, its own tmux session, its own tab identity. It is not a
|
||||
location overlay like Docker or remote-SSH cases, and it is not a web tab.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://omp.sh/install | sh
|
||||
```
|
||||
|
||||
The installer places the binary in `~/.local/bin` (verified against a real
|
||||
`--no-cache` Docker build — see `docker/agent.Dockerfile`; an earlier guess of
|
||||
`~/.omp/bin` was wrong). Codeman resolves the binary via the server PATH and then
|
||||
the usual install locations (`~/.local/bin` first, then `~/.omp/bin`,
|
||||
`/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`).
|
||||
|
||||
**`omp` is a short name**, so like `pi` and `grok` the resolver does not trust a PATH
|
||||
hit on its own: it runs `omp --version` and requires `omp/<semver>`-shaped output
|
||||
(e.g. `omp/18.0.8`) before accepting a candidate. Check what it resolved:
|
||||
|
||||
```bash
|
||||
curl -s localhost:3000/api/omp/status | jq
|
||||
# { "available": true, "path": "/home/you/.local/bin", "version": "18.0.8" }
|
||||
```
|
||||
|
||||
## Authenticate
|
||||
|
||||
OMP owns its own auth and provider configuration entirely in `~/.omp` — there is
|
||||
no Codeman-side login flow, API key field, or bypass switch to configure. Run `omp`
|
||||
directly once outside Codeman to complete whatever onboarding the CLI itself asks
|
||||
for; every session started through Codeman afterward inherits that config.
|
||||
|
||||
## What Codeman wires up
|
||||
|
||||
`OmpConfig` (per session, persisted in `state.json`, round-trips through respawn):
|
||||
|
||||
| Field | Flag | Notes |
|
||||
| ------------------ | --------------- | ---------------------------------------------------------- |
|
||||
| `model` | `--model <v>` | Regex-validated (`[a-zA-Z0-9._-/]+`); `provider/model` forms like `crof/glm-5.2` pass |
|
||||
| `continueSession` | `--continue` | omp's own "most recent conversation in this directory" heuristic |
|
||||
| `resumeSessionId` | `--resume <id>` | Ids only, id-regexed; wins over `--continue` when both are present |
|
||||
|
||||
Every value is regex-validated and **dropped** (not escaped) if it fails, because the
|
||||
result is interpolated into the pane's spawn command.
|
||||
|
||||
**omp reads its own model routing and hooks from `~/.omp`, so no trust or
|
||||
permission flags are needed** — unlike every sibling CLI in this family, there is no
|
||||
bypass-permissions equivalent to wire up, so `buildOmpCommand()` only ever passes
|
||||
`--model`/`--resume`/`--continue`. ⚠️ That does NOT mean omp is unrestricted: its
|
||||
documented default `tools.approvalMode` is `yolo`, so an omp pane auto-approves exec
|
||||
with no flag from Codeman — the CLI's own config, not Codeman, is what would need to
|
||||
change that.
|
||||
|
||||
Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own
|
||||
`docs/environment-variables.md` it is not the narrow surface it looks like. omp reads
|
||||
roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
||||
`XAI_API_KEY`, `HF_TOKEN`, ...) — pi's 34-key problem in the same shape — which is why
|
||||
none of those get a dedicated allowlist entry; a session authenticates from `~/.omp`
|
||||
config or the server process's own env instead, like pi. omp's own documented knobs
|
||||
are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`,
|
||||
`PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`,
|
||||
`OMP_PROFILE`/`PI_PROFILE`), and `PI_*` is already allowlisted globally because pi
|
||||
mode needs it — so an omp session today already accepts all of those. The first three
|
||||
also move the tree `omp-session-resolver.ts` and `omp-transcript.ts` hardcode
|
||||
(`resolveOmpHome()` assumes `~/.omp` unconditionally), so pinning and history quietly
|
||||
stop working under a redirected config root; this is a known gap, not fixed here.
|
||||
|
||||
The `OMP_` prefix itself brings in `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`,
|
||||
where omp resolves credentials from — the same shape `DEEPSEEK_BASE_URL` is dropped
|
||||
for in `clampEnvOverridesForOwner()` (session-routes.ts), so both are clamped there
|
||||
for a non-granted owner in multi-user mode. None of this matters in single-user mode.
|
||||
|
||||
## Exact-id pinning: why `--resume`, not just `--continue`
|
||||
|
||||
`--continue` alone is ambiguous the moment **any** other omp conversation has
|
||||
touched the same working directory more recently — it just picks the newest session
|
||||
file on disk, silently. That happens routinely: a closed-then-resumed Codeman row
|
||||
plus a still-running duplicate, two Codeman sessions pointed at the same case, or a
|
||||
plain reattach after a server restart.
|
||||
|
||||
`src/utils/omp-session-resolver.ts` resolves and **pins** the exact conversation id
|
||||
once (`findLatestOmpSessionId()` reads `~/.omp/agent/sessions/<mangled-workingDir>/`,
|
||||
the newest `.jsonl` file's embedded uuid), then every later respawn reuses that
|
||||
pinned id via `--resume` instead of re-guessing with `--continue`.
|
||||
|
||||
⚠️ **The directory mangling is NOT a straight `/` → `-` replace.** Unlike Claude
|
||||
Code's `~/.claude/projects/*` convention (which keeps the full path, e.g.
|
||||
`-home-user-codeman-cases-foo`), omp strips the `$HOME` prefix FIRST and only then
|
||||
dash-replaces (`/home/user/codeman-cases/foo` → `-codeman-cases-foo`; a path outside
|
||||
`$HOME`, like `/tmp/...`, is dash-replaced as-is with no stripping). Getting this
|
||||
wrong doesn't error — `findLatestOmpSessionId()` just silently returns null for
|
||||
every case under `$HOME` (virtually all real Codeman cases), so pinning quietly
|
||||
degrades to omp's own ambiguous `--continue`. This was found and fixed 2026-08-27
|
||||
after months of testing had only ever exercised `/tmp`-based working directories,
|
||||
where the bug's wrong output happened to coincidentally match the right one.
|
||||
|
||||
## Surviving a full session kill
|
||||
|
||||
`src/omp-transcript.ts` scans `~/.omp/agent/sessions/**/*.jsonl` directly — a second,
|
||||
independent history source alongside Codeman's own state. This means an OMP
|
||||
conversation's history (working directory, first/last prompt, size) is recoverable
|
||||
in the Past Sessions list even when **both** the Codeman session record and the
|
||||
underlying tmux pane are gone — verified live against a full OS reboot, not just a
|
||||
"Kill Tmux" button click.
|
||||
|
||||
## Terminal behavior
|
||||
|
||||
OMP renders inside tmux like every external CLI (narrow scrollback strip — alt-screen
|
||||
toggles only, not the full Claude/Codex/Gemini strip). It stays out of the
|
||||
alt-screen-strip list and lands on the `'buffer'` local-echo policy via the
|
||||
`_updateLocalEchoState` fallthrough, same as grok and pi.
|
||||
|
||||
## Docker cases
|
||||
|
||||
The agent image installs omp in its own Dockerfile step (not npm; omp's installer
|
||||
targets `$HOME/.local/bin` with no `--dir` override, the same shape as grok's
|
||||
installer). Rebuild with the mandatory `--no-cache`:
|
||||
|
||||
```bash
|
||||
node scripts/build-agent-image.mjs --no-cache
|
||||
```
|
||||
|
||||
⚠️ **`--resume` pinning does not currently reach an in-container omp process.**
|
||||
Docker panes are built from `defaultDockerCommandForMode`, which never sees
|
||||
`ompConfig` — `appendResumeFlag()`'s `case 'omp'` keys off the top-level
|
||||
`resumeSessionId` field, which nothing populates for omp today. Host-side history
|
||||
recovery still works (the shared `sessions/` mount below), but a respawned
|
||||
in-container omp pane falls back to its own ambiguous `--continue`, not a pinned
|
||||
id. Flagged in upstream review, not yet fixed.
|
||||
|
||||
Credentials are **mostly seeded**, but `sessions/` is the one exception in this CLI
|
||||
family: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded
|
||||
(read-only mount, copied into the container's own `~/.omp/agent` once), so an
|
||||
in-container omp never writes refreshed config back to the host and `docker commit`
|
||||
exports stay secret-free. But `~/.omp/agent/sessions/` is **shared (RW)**, not
|
||||
seeded — the same treatment as codex's `sessions/`, and for the identical reason:
|
||||
Codeman reads it host-side (`omp-transcript.ts`, `omp-session-resolver.ts`) for
|
||||
history recovery and `--resume` pinning. Seeding it instead of sharing it would make
|
||||
an in-container OMP conversation invisible to Codeman's own history/resume logic,
|
||||
silently breaking Docker support for the kill-survival feature above. The rest of
|
||||
`~/.omp/agent` (`agent.db`/`history.db`/`models.db` SQLite caches,
|
||||
`terminal-sessions/`, `blobs/`, `cache/`) stays container-local and is neither
|
||||
shared nor seeded.
|
||||
|
||||
## Remote SSH cases
|
||||
|
||||
`omp` mode is routed through an interactive login shell
|
||||
(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not
|
||||
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
|
||||
rejected rather than silently ignored; use the per-host command override instead.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
||||
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
|
||||
POSTing to `/api/hook-event` would be the highest-value follow-up.
|
||||
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
||||
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
||||
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||
does not). This is not something Codeman can compensate for from outside the
|
||||
process; it would need an upstream omp fix (e.g. flush-on-SIGTERM).
|
||||
- **Unverified: `$HOME` as a symlink.** The directory-mangling fix above compares
|
||||
against the literal `homedir()` string, not a `realpath()`-resolved one. Whether
|
||||
omp itself canonicalizes symlinks before mangling is unconfirmed — this has not
|
||||
been tested against a symlinked-home setup.
|
||||
- Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are
|
||||
off for omp, as for every external CLI.
|
||||
@@ -139,7 +139,7 @@ set -g extended-keys-format csi-u
|
||||
|
||||
Codeman's browser input path sends `\r` for submit, so basic use works
|
||||
unconfigured — what degrades is newline-in-editor, mostly when you attach to the
|
||||
pane directly (`sc`).
|
||||
pane directly (`codeman tui`).
|
||||
|
||||
⚠️ Upstream notes the setting may need a full `tmux kill-server` to take effect.
|
||||
**Never run `tmux kill-server` on Codeman's socket** — it would kill every live
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Remote Sessions (SSH)
|
||||
|
||||
Codeman can run a session's agent on a **remote host over SSH** instead of the
|
||||
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, Pi, or a plain shell)
|
||||
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or a plain shell)
|
||||
runs inside a `tmux` server **on the remote host**, so it survives the SSH
|
||||
connection dropping; Codeman attaches to it the same way it attaches to a local
|
||||
managed session.
|
||||
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi'>` — the modes that can run remotely. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
|
||||
@@ -489,7 +489,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||
|
||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, and five seeded files from `~/.pi/agent`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, and three from `~/.grok`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# Codeman TUI Rework Plan
|
||||
|
||||
Status: **phases 0-2 implemented** on `feat/tui`; phases 3-4 remain follow-ups. The user guide is [`docs/tui.md`](tui.md); this document stays the design record.
|
||||
|
||||
- Phase 0: `src/cli-style.ts` (palette, glyphs, `heading`/`kv`/`table`/`spinner`/`confirm`) plus the mechanical fixes of §5, and `test/cli-commands.test.ts` now derives its inventory from the real commander `program` instead of parsing a fixture.
|
||||
- Phases 1-2: `src/tui/`. `tui-app.ts` (main loop, attach handoff, verbs) and `tui-client.ts` (API, SSE, degraded enumeration) are the only IO; `tui-model`, `tui-layout`, `tui-render`, `tui-keys`, `tui-ansi`, `tui-composer`, `tui-approvals`, `tui-digest`, `tui-sse` and `tui-types` are pure and unit-tested, with an E2E suite driving the real binary under node-pty.
|
||||
- Deferred with the rest of phase 3: `r` (resume a RECENT row) is not wired up, so the help overlay does not advertise it.
|
||||
- Not started: phase 3 (mouse, `--pick` popup switcher, opt-in attach status line, OSC 9) and phase 4 (retiring the bash choosers).
|
||||
|
||||
The goal: replace Codeman's scattered terminal surfaces with one first-class TUI, `codeman tui`, that gives SSH/terminal users the same at-a-glance awareness the web UI gives browsers. The reference point is herdr (herdr.dev), the trending Rust "agent multiplexer" whose defining feature is a live agent-state sidebar. Codeman can match and beat that sidebar in the terminal because the states herdr infers from screen-scraping heuristics are states our server already computes from hooks, pane probing, and the approvals inbox.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we have today (inventory)
|
||||
|
||||
Three disconnected surfaces, three visual idioms, two data sources:
|
||||
|
||||
| Surface | What it is | Data source | Idiom |
|
||||
| --- | --- | --- | --- |
|
||||
| `codeman` CLI (`src/cli.ts`, 1214 lines) | commander + chalk, ~20 commands | HTTP API + state files | `✓`/`✗` line-per-fact, no interactivity |
|
||||
| `sc` (`scripts/tmux-chooser.sh`, 663 lines) | bash number-menu chooser, mobile-tuned (44 cols) | `tmux -L codeman` + `state.json` via jq | 256-color, numbered, full repaint per key |
|
||||
| `scripts/tmux-manager.sh` (529 lines) | bash cursor TUI with kill/info | `mux-sessions.json` (and writes it back) | 8-color, box-drawn, arrow keys |
|
||||
|
||||
Weaknesses found in the audit (file:line refs verified 2026-08-16):
|
||||
|
||||
1. **No interactive picker in the Node CLI at all.** Every `session stop`, `task status`, `session logs` requires a pasted UUID prefix. There is no `codeman attach <session>`; `codeman attach` is actually the attachment-card command (and `README.md:895` describes it wrongly).
|
||||
2. **`sc` cannot reach sessions 10+ interactively**: entries are numbered globally (`tmux-chooser.sh:343`) but input accepts a single `[1-9]` keypress (`:487-493`). Page 2 shows items 8-14 that mostly cannot be selected.
|
||||
3. **No cursor/selection concept in `sc`** (`BG_SEL` at `:90` is dead code); arrows only page.
|
||||
4. The two bash tools can disagree about which sessions exist (different data files), and only `sc` is on PATH.
|
||||
5. **Zero live feedback anywhere**: `codeman web -d` and `service install` block silently up to 30s (`daemon-control.ts:395-412`); no spinner exists in the codebase.
|
||||
6. Styling drift: `doctor` is the only table and is deliberately monochrome with a colorize hook nobody wired up (`dependency-report.ts:5-7`); `codeman web` prints its "running at" line twice (colored `cli.ts:934`, plain `server.ts:2366`); the server's security warning is colorless `console.warn` while the CLI's version of the same warning is yellow; `tmux-manager.sh`'s header box is visibly misaligned; `padEnd(14)` overflows on "Antigravity CLI".
|
||||
7. Bash TUIs emit raw escapes unconditionally (no TTY/NO_COLOR gate); `install.sh` and `postinstall.js` do it right.
|
||||
8. Detach hint inconsistency: chooser says Ctrl+B D, `README.md:671` says Ctrl+A D.
|
||||
9. Inside an attached session there is **no chrome at all**: Codeman turns the tmux status bar off (`tmux-manager.ts:1978`), so an SSH user in a pane has no session identity, no state, no way back to a picker except detach.
|
||||
10. `test/cli-commands.test.ts` asserts against a hand-written fixture, not the real `program`, and that fixture already lists a `tui` command that does not exist (`:57-61`). The name is pre-approved by our own test file.
|
||||
|
||||
## 2. Research: how herdr does it
|
||||
|
||||
herdr (github.com/herdrdev/herdr, ~30k stars, single Rust binary, pre-1.0) is a background terminal multiplexer "your coding agents live on". What matters for us:
|
||||
|
||||
- **The agent-state sidebar is the product.** Every pane is classified live as `working` / `blocked` / `done` / `idle` and grouped in a sidebar, so you see who needs you without switching tabs. Reviews unanimously call this "the killer feature tmux can't match".
|
||||
- **Detection is heuristic-first**: process-name matching + screen-manifest TOML rules parsing the visible frame; optional per-agent "integration install" adds lifecycle hooks over JSON-RPC on a unix socket for accurate states. Claude Code there is on the heuristic path and reviewers note blocked-state lag.
|
||||
- **Model**: workspaces → tabs → panes, tmux-style prefix keys (Ctrl+B V split, arrows navigate, D detach), mouse-first (click select, drag resize, right-click menus, touch over SSH), adapts to narrow widths.
|
||||
- **Agent-shaped API**: socket API with `pane read` (visible/recent/detection), `send-text`/`send-keys`/`run`, `agent start|prompt|wait|explain`, `pane wait-output` with regex, plugins placed as overlay/split/tab/popup.
|
||||
- **Persistence**: sessions survive disconnects, reattach from any terminal / SSH.
|
||||
- Weaknesses reviewers cite: pre-1.0 churn, bus factor 1, no session resurrection, rendering lag with many panes.
|
||||
|
||||
What is striking is how much of herdr Codeman already has, server-side: our hooks give exact `permission_prompt`/`stop`/`idle_prompt` events (herdr's "integration" path, but installed by default), `_confirmIdle()` does the screen-probe fallback, the approvals inbox parses the actual dialog options, and the agent skill + wait primitives are our socket API. What we lack is purely the presentation layer in the terminal.
|
||||
|
||||
Prior art for the architecture we want: **agent-deck** (Bubble Tea + tmux) proves the "TUI list + attach into tmux" model works great: session list with live glyphs (● ◐ ○ ✕), Enter attaches into a tmux pane, status polling, groups, fuzzy search. We take the shape, not the code.
|
||||
|
||||
Licensing note: herdr is reported variously as Apache-2.0/AGPL-3.0. Irrelevant either way: we copy concepts, never code.
|
||||
|
||||
### What we take / what we skip
|
||||
|
||||
Take: the four-state sidebar as the organizing principle; grouping by "needs you first"; narrow-width adaptation; mouse support; tmux-familiar keys; the "attention at a glance" framing.
|
||||
|
||||
Skip: being a multiplexer. tmux already backs every Codeman session and is a hard dependency; herdr had to build pane management because it owns terminals, we do not. Also skip (for now): plugin marketplace, split layouts, pane drag. Our TUI is a **dashboard + switchboard over tmux**, not a tmux replacement.
|
||||
|
||||
## 3. Design: `codeman tui`
|
||||
|
||||
One command, one full-screen client of the existing HTTP/SSE API.
|
||||
|
||||
**Positioning (owner decision, 2026-08-16): the web UI remains THE primary surface.** The TUI is strictly additive, for users who want a terminal workflow (SSH, Termius, tmux die-hards). Bare `codeman` keeps printing help; nothing existing changes behavior. The `sc` bash chooser also stays untouched for now; flipping its alias to `codeman tui` is deferred to a follow-up release once the TUI has mileage.
|
||||
|
||||
### Layout (≥100 cols)
|
||||
|
||||
```
|
||||
codeman tnode · v1.19.0 · 6 sessions · 5h ▂▂▅ 32% wk 61% ? help q quit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────
|
||||
NEEDS YOU ──────────────────────────┐ ┌ w4-api-refactor ── claude · ~/dev/api ────────────
|
||||
▶ 1 w4-api-refactor ⚠ approval 2m │ │ ✻ Actualizing… (2m 14s · ↓ 12.3k tokens)
|
||||
2 w6-docs ✋ waiting 11m │ │
|
||||
│ │ ⚠ Claude requests: Bash(git push origin main)
|
||||
WORKING ────────────────────────────┤ │ 1. Yes 2. Yes, don't ask again 3. No
|
||||
3 w1-codeman ✻ 17m 45.2k │ │
|
||||
4 w2-gallery ✻ 3m 8.1k │ │ [y] approve [n] deny [Enter] attach
|
||||
IDLE ───────────────────────────────┤ │
|
||||
5 w3-promo ○ 2h │ │ …live tail of the selected session's
|
||||
RECENT ─────────────────────────────┤ │ terminal (ANSI colors preserved),
|
||||
· api-hotfix ✔ done Fri │ │ updating while you browse the list…
|
||||
────────────────────────────────────────────────────────────────────────────────────────────
|
||||
↑↓ select · ⏎ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest
|
||||
```
|
||||
|
||||
- **Header**: hostname/instance, server version, session count, plan-usage chip (same telemetry that feeds the web chip, when available). Degrades gracefully when the server is down (see §3.6).
|
||||
- **Sidebar**: sessions grouped `NEEDS YOU` → `WORKING` → `IDLE` → `RECENT` (past sessions from the unified list, resumable). Within groups, reuse the activity ordering already built for the home screens in PR #303 (blocked first, running longest, quiet newest); that logic is pure and shared.
|
||||
- **Preview pane**: live tail of the selected session, SGR colors preserved, cursor-movement stripped. When the selected session has a pending approval, the parsed dialog is rendered as a card above the tail with one-key answer bindings.
|
||||
- **Footer**: contextual keymap (changes when a dialog/confirm is active).
|
||||
|
||||
### States and vocabulary
|
||||
|
||||
Exactly the web's language so the two surfaces read the same:
|
||||
|
||||
| Group | Glyph | Color | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| NEEDS YOU (question/permission) | `⚠` | red, blinking row | approvals inbox / `permission_prompt` |
|
||||
| NEEDS YOU (waiting for input) | `✋` | yellow | `idle_prompt` / waiting classification |
|
||||
| WORKING | `✻` animating through `· ✢ ✳ ∗ ✻ ✽` at 2Hz | green | working classification (the same glyph family Claude itself draws, a deliberate nod) |
|
||||
| IDLE | `○` | muted | idle |
|
||||
| RECENT / done | `✔` | muted green | unified list history rows |
|
||||
|
||||
Nerd-font/glyph fallback exactly like `sc` does today (`[!] [w] [*] [-] [ok]` when the terminal is not known-capable), plus full NO_COLOR / `tput colors` degradation (8-color and mono renderings are designed, not accidental).
|
||||
|
||||
### Keymap
|
||||
|
||||
- `↑/↓` or `j/k` select · `Enter` attach · `1-9` jump-attach (parity with `sc`, but now the cursor covers 10+)
|
||||
- `y`/`n` (or the digit keys) answer the selected session's pending approval right from the dashboard, via `POST /api/approvals/:id/answer`. The server already re-captures the pane and 409s if the dialog is gone, so this is safe by construction.
|
||||
- `p` send a one-line prompt to the selected session without attaching (`POST /input` with `\r`, the composer opens in the footer)
|
||||
- `n` new session (case picker → mode picker, drives `POST /api/quick-start`) · `x` kill with typed confirm (never bulk; refuses the session hosting the TUI itself, like tmux-manager.sh does)
|
||||
- `/` fuzzy search across sessions/history/attachments (`GET /api/search`) · `g` away digest (`GET /api/away-digest`) rendered as a panel
|
||||
- `r` resume selected RECENT row (unified list `resume-session` flow) · `?` help overlay · `q` quit
|
||||
- Mouse (phase 3): SGR mouse reporting, click selects, wheel scrolls list/preview, click on footer keys triggers them. Works over SSH, same as herdr's touch story.
|
||||
|
||||
### Responsive behavior
|
||||
|
||||
The `sc` design constraint survives: below ~72 cols (Termius, iPhone portrait) the preview pane drops and the TUI is a single-column list with two-line rows, nearly identical to today's `sc` but with a cursor, live states, and the answer/prompt/new/kill verbs. The layout switch is width-driven at draw time, no mode flag.
|
||||
|
||||
### Attach model
|
||||
|
||||
Enter suspends the TUI (restore main screen + cooked mode), then hands the terminal to `tmux -L <socket> attach-session -t <name>` with `stdio: inherit`. On tmux exit/detach, the TUI resumes and refreshes. Full fidelity (mouse, paste, colors) is tmux's, we never proxy bytes.
|
||||
|
||||
- Inside tmux already: same socket → `switch-client -t`; different socket → warn about nesting and offer detach-first. `$TMUX` + `CODEMAN_MUX` detection.
|
||||
- **Return path**: a tmux binding installed for codeman sessions (opt-in) runs `codeman tui --pick` inside `tmux display-popup -E`, a minimal picker-only mode (list + jump, no preview) so switching sessions from inside a pane is one keystroke, fzf-style.
|
||||
- Optional per-attach chrome (opt-in setting, default off since `status off` at `tmux-manager.ts:1978` is deliberate): a minimal codeman-styled tmux status line showing `name · state · alert`, set on attach, restored on detach.
|
||||
|
||||
### Notifications
|
||||
|
||||
While the TUI is open and a session flips to NEEDS YOU: flash the row, ring BEL, and optionally emit OSC 9 (desktop notification in kitty/WezTerm/iTerm2, and it traverses SSH). This is the herdr sidebar promise delivered even when the terminal is backgrounded.
|
||||
|
||||
### Degraded mode (server down)
|
||||
|
||||
`sc` works without the server today and the TUI must too: when no server answers, enumerate `tmux -L codeman list-sessions` + read `state.json` (read-only), show a "server not running" header line, and offer attach only (no states, no approvals). This keeps the "web server crashed, get me to my sessions" path alive.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### A client of the server, not a second brain
|
||||
|
||||
Everything live comes from the API the web UI already uses:
|
||||
|
||||
| Need | Endpoint |
|
||||
| --- | --- |
|
||||
| Session list + history | `GET /api/sessions/unified` |
|
||||
| Live updates | SSE `GET /api/events` (heartbeat `sse:heartbeat` already exists; fall back to 2s polling) |
|
||||
| Pending approvals + parsed options | `GET /api/approvals`, answer via `POST /api/approvals/:id/answer` |
|
||||
| Preview tail | `GET /api/sessions/:id/terminal?tail=N` (throttled to the selected session only) |
|
||||
| Prompt send | `POST /api/sessions/:id/input` (single line + `\r`, per the composer contract) |
|
||||
| New session | `POST /api/quick-start` (routes remote/docker cases correctly) |
|
||||
| Search | `GET /api/search` |
|
||||
| Away digest | `GET /api/away-digest` |
|
||||
| Plan usage chip | latest status-telemetry snapshot (`plan-usage-latest`) |
|
||||
|
||||
Server discovery and auth reuse what exists: instance config from `src/config/instance.ts` (`CODEMAN_INSTANCE`, `CODEMAN_PORT`), the probe logic from `daemon-control.ts`, credentials from `~/.codeman/.env` (the established `codeman attach` pattern), self-signed HTTPS accepted for loopback probes (the hooks-on-HTTPS lesson). Multi-user scoping comes free: the API only returns what the authenticated user owns.
|
||||
|
||||
### Renderer: hand-rolled, zero new dependencies (decision)
|
||||
|
||||
Options considered:
|
||||
|
||||
- **Ink (React for CLIs)**: what Claude Code uses. Pros: layout engine, ecosystem. Cons: pulls React into a CLI that today ships only commander+chalk; rerender model fights the two things we care most about (a raw-ANSI preview region and 2Hz glyph animation without flicker); version-pins React for every `npm i -g aicodeman`.
|
||||
- **blessed/neo-blessed**: unmaintained, skip.
|
||||
- **Hand-rolled screen core** (recommended): this repo hand-rolls ANSI everywhere already and has the expertise (regex-patterns, stripAnsi, the xterm work). The core is small and boring: alt screen + raw mode + cursor-home full-frame repaint from an off-screen string buffer, throttled to state changes and the 2Hz animation tick, wrapped in DECSET 2026 (synchronized output) where supported so repaints are atomic in modern terminals (tmux, kitty, WezTerm, iTerm2). No diffing needed at these frame rates.
|
||||
|
||||
The one genuinely tricky pure function: SGR-aware line clipping for the preview (keep colors, strip cursor movement/OSC/DECSET, clip to width while carrying SGR state, reset at EOL). That is a pure module with exhaustive unit tests, and it is exactly the kind of function Ink would not have given us anyway.
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
src/tui/
|
||||
tui-app.ts entry + main loop + attach handoff (IO)
|
||||
tui-client.ts API + SSE client, degraded-mode enumeration (IO)
|
||||
tui-model.ts pure: state store, grouping, ordering (reuses PR #303 helpers)
|
||||
tui-layout.ts pure: responsive layout math, row building
|
||||
tui-render.ts pure: model+layout -> frame string (palette, glyphs, fallbacks)
|
||||
tui-keys.ts pure: byte stream -> key/mouse events (incl. SGR mouse decode)
|
||||
tui-ansi.ts pure: SGR-aware clip/filter for the preview
|
||||
```
|
||||
|
||||
Pure modules unit-test with no TTY. `cli.ts` gains one thin `tui` command registration (and `--list`/`<n>` fast paths for `sc -l` / `sc 2` parity, which must stay fast: they short-circuit before any screen setup).
|
||||
|
||||
## 5. CLI-wide polish (the rest of "make it much nicer")
|
||||
|
||||
A shared style kit, `src/cli-style.ts`: one palette (mirroring the web's status colors), one glyph set with fallback, `heading()`, `kv()`, `table()` (width-aware, fixes the Antigravity overflow), `spinner()` (finally: the 30s silent daemon/service waits get a live line), `confirm()` (used by `reset --force`'s missing prompt and `x` in the TUI). Then the mechanical fixes from §1: colorize `doctor` through the hook that already exists for it, dedupe the `codeman web` startup line, colorize the server's security warning, fix the README `codeman attach` description and the Ctrl+B/Ctrl+A detach drift, TTY/NO_COLOR gates everywhere.
|
||||
|
||||
## 6. Phasing
|
||||
|
||||
| Phase | Contents | Size |
|
||||
| --- | --- | --- |
|
||||
| 0 | `cli-style.ts` + mechanical fixes (§5), real CLI tests (retire the fixture parser in `test/cli-commands.test.ts`) | S |
|
||||
| 1 | `codeman tui` core: list + states via SSE, cursor + 1-9, attach/return loop, kill w/ confirm, new session, narrow mode, degraded mode, `sc` alias flip + `--list`/`<n>` parity | M/L |
|
||||
| 2 | Preview pane (SGR clip), approvals answering, prompt composer, search, digest, resume, plan-usage header | M |
|
||||
| 3 | Mouse support, `--pick` popup switcher + tmux binding, opt-in attach status line, BEL/OSC 9 notifications | M |
|
||||
| 4 | Retire `tmux-chooser.sh`/fold `tmux-manager.sh` (keep as thin wrappers for one release), docs/README/wiki, screenshots for promo | S |
|
||||
|
||||
Phases 0-1 are the useful minimum; 2 is where it beats herdr's sidebar (answering approvals from the dashboard); 3 is delight.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- Pure modules (`tui-model/layout/render/keys/ansi`): plain vitest, frame snapshots as stripped strings plus targeted ANSI assertions.
|
||||
- Interactive E2E: spawn the built TUI under `node-pty` (already a dependency), feed keys, assert on captured frames; the vitest tmux mock (`IS_TEST_MODE`) keeps attach paths inert. Port rules per CLAUDE.md (3150+, `app.inject()` where possible by testing `tui-client` against injected routes).
|
||||
- Manual: Termius/iPhone portrait (the 44-col case), tmux nesting, server-down mode, NO_COLOR, non-nerd-font terminal.
|
||||
|
||||
## 8. Invariants this plan respects
|
||||
|
||||
- tmux socket and data dir always via instance config (`dataPath()`, `-L codeman`); a beta instance TUI sees only its own world.
|
||||
- Never bulk kill, always confirm, never touch another session implicitly, refuse killing the session the TUI runs in (w1/w2/w3 are sacred).
|
||||
- Input is single-line with `\r`, via the server (never raw tmux send-keys from the TUI while the server owns the session).
|
||||
- Approvals answering goes through the server's re-capture + 409 path, never blind keystrokes.
|
||||
- `status off` on panes stays the default; any chrome is opt-in.
|
||||
- No new runtime dependencies; the npm package stays light.
|
||||
|
||||
## 9. Decisions (resolved 2026-08-16)
|
||||
|
||||
1. **Bare `codeman` does NOT open the TUI** (owner decision): the web UI is the main thing, the TUI is additional. `codeman tui` only.
|
||||
2. **`sc` stays the bash chooser for now**; the alias flip is a follow-up once the TUI has mileage. `codeman tui --list` / `codeman tui <n>` provide the same fast paths for people who want to switch.
|
||||
3. Opt-in tmux status line: deferred to phase 3 along with the `--pick` popup switcher.
|
||||
4. Preview tail goes over the API (auth/multi-user/remote-consistent); previews are simply unavailable in degraded server-down mode.
|
||||
5. Name is `codeman tui` (the test fixture historically expected it).
|
||||
|
||||
Initial PR scope: phases 0-2. Phase 3 (mouse, popup switcher, status line, OSC 9) and phase 4 (bash chooser retirement) are follow-ups.
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# Terminal UI (`codeman tui`)
|
||||
|
||||
`codeman tui` is a full-screen dashboard for your Codeman sessions, in the terminal.
|
||||
It shows every session grouped by whether it needs you, lets you answer a permission
|
||||
dialog or send a prompt without switching anywhere, and puts you inside a session's
|
||||
tmux pane with one keystroke.
|
||||
|
||||
It is **additional, not a replacement**: the web UI stays the primary surface and
|
||||
gets every feature first. The TUI exists for the terminal workflow (SSH, Termius,
|
||||
a tmux window you keep open all day), and it is a *client* of the running server,
|
||||
so the two surfaces can never disagree about what a session is doing. It is also
|
||||
not a multiplexer: tmux still owns every pane, and attaching hands the terminal to
|
||||
tmux rather than proxying bytes.
|
||||
|
||||
## Starting it
|
||||
|
||||
```bash
|
||||
codeman tui # the dashboard
|
||||
codeman tui --list # print the numbered session list and exit
|
||||
codeman tui 2 # attach straight to session 2 of that list
|
||||
```
|
||||
|
||||
The two fast paths are the scriptable ones.
|
||||
Neither sets up a screen, so both are as quick as the one API call they make, and
|
||||
`--list` prints plain text when piped, so it composes with `grep`/`awk`.
|
||||
|
||||
What it needs:
|
||||
|
||||
| Needs | What you get |
|
||||
| --- | --- |
|
||||
| **Full features** | A running Codeman server (states, approvals, preview, prompts, search, digest). The TUI finds it the way `codeman attach` does: `CODEMAN_API_URL`, else loopback on `CODEMAN_PORT` for this `CODEMAN_INSTANCE`. The self-signed certificate an `--https` install generates is accepted, as it is everywhere else in the CLI. |
|
||||
| **Server down** | It still starts, in **degraded mode**: sessions are enumerated straight from `tmux -L codeman` plus a read-only peek at `state.json`, and attach is the only verb. See [Troubleshooting](#troubleshooting). |
|
||||
| **A terminal** | `codeman tui` refuses to run when stdin/stdout are not a TTY, and says to use `--list` instead. A cron job or a pipe therefore fails loudly rather than emitting escape codes into a log. |
|
||||
|
||||
## What it looks like
|
||||
|
||||
A real frame at 100x30 (`NO_COLOR`, trailing blank rows trimmed). The selected
|
||||
session has a pending permission dialog, so the preview pane leads with the card:
|
||||
|
||||
```
|
||||
codeman ⚠ 2 tnode · v1.19.0 · 5 sessions · 5h 32% · wk 61% ? help q quit
|
||||
NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/you/dev/api · blocked
|
||||
1 w6-docs ✋ 11m│ ⚠ requests: Bash(git push origin main)
|
||||
▶ 2 w4-api-refactor ⚠ 2m│ 1. Yes
|
||||
WORKING ───────────────────────────│ 2. Yes, and do not ask again
|
||||
3 w1-codeman ∗ 1h│ 3. No, tell Claude what to do
|
||||
4 w2-gallery ∗ 15m│ y approve · n deny · digit chooses
|
||||
IDLE ──────────────────────────────│
|
||||
5 w3-promo shell ○ 2h│ > refactor the api routes onto the shared port interface
|
||||
RECENT ────────────────────────────│
|
||||
6 api-hotfix ✔ 3d│ Read src/web/ports/session-port.ts (48 lines)
|
||||
│ Read src/api/routes.ts (312 lines)
|
||||
│ Edit src/api/routes.ts
|
||||
│ 1 -import { SessionManager } from "../session-manager.js";
|
||||
│ 2 +import type { SessionPort } from "../web/ports/session-
|
||||
│
|
||||
│ Bash(npm run typecheck)
|
||||
│ └ tsc --noEmit: no errors
|
||||
│
|
||||
│ ✻ Actualizing… (2m 14s · ↓ 12.3k tokens)
|
||||
↑↓ select · ⏎ attach · y approve · n deny · 1-9 option · p prompt · x kill · / search · g digest ·
|
||||
```
|
||||
|
||||
- **Header**: the machine, the server version, how many sessions are live, and the
|
||||
plan-usage chip (the same statusLine telemetry that feeds the web chip, when the
|
||||
server has a snapshot). A `⚠ n` badge counts pending approvals.
|
||||
- **Sidebar**: every session, grouped and numbered.
|
||||
- **Preview**: a live tail of the selected session, its own colors preserved, with
|
||||
the parsed dialog card on top when that session is blocked.
|
||||
- **Footer**: only the keys that work right now. `n` reads `n new` normally and
|
||||
`n deny` when the selected session has a dialog, because it cannot be both.
|
||||
|
||||
The same world through `--list`:
|
||||
|
||||
```
|
||||
1 waiting w6-docs /home/you/dev/docs
|
||||
2 blocked w4-api-refactor /home/you/dev/api
|
||||
3 working w1-codeman /home/you/dev/codeman
|
||||
4 working w2-gallery /home/you/dev/gallery
|
||||
5 idle w3-promo /home/you/dev/promo
|
||||
6 done api-hotfix /home/you/dev/api
|
||||
```
|
||||
|
||||
The numbers are the same on both surfaces, so `codeman tui --list` then
|
||||
`codeman tui 4` is one thought.
|
||||
|
||||
## The four groups
|
||||
|
||||
Groups are always in this order, and a session is in exactly one of them:
|
||||
|
||||
| Group | Glyph | Means | Comes from |
|
||||
| --- | --- | --- | --- |
|
||||
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed) |
|
||||
| | `✋` | Waiting for your next instruction, or errored | `idle_prompt`, or an errored session (equally something only a human clears) |
|
||||
| **WORKING** | `✻` animating | A turn is running | The same working classification the web dashboard uses |
|
||||
| **IDLE** | `○` | Live, but sitting there | |
|
||||
| **RECENT** | `✔` | A past session from the unified list | History rows, no live pane |
|
||||
|
||||
Ordering inside a group is "the one that has waited longest, first": blocked
|
||||
sessions sort by how long the dialog has been up, working sessions by when their
|
||||
turn started (the pane's last Enter, since a working pane repaints every second
|
||||
and would otherwise always look freshly started), and quiet ones by last activity.
|
||||
That is the ordering the web home screens already use.
|
||||
|
||||
The cursor sticks to a **session**, not a row number, so a session that jumps to
|
||||
NEEDS YOU does not drag your selection with it. The number beside each row is what
|
||||
`1-9` and `codeman tui <n>` mean, and it is renumbered on every re-sort.
|
||||
|
||||
When a new dialog appears, the terminal bell rings once, for that dialog only: the
|
||||
same item announced twice does not ring twice.
|
||||
|
||||
## Keymap
|
||||
|
||||
| Key | Does |
|
||||
| --- | --- |
|
||||
| `↑` `↓` or `j` `k` | Move the cursor. PageUp/PageDown jump five rows. |
|
||||
| `Enter` | Attach to the selected session (see [Attaching](#attaching)) |
|
||||
| `1`-`9` | Jump to that row and attach. When a dialog is on screen, a digit answers it instead (see below). |
|
||||
| `y` | Approve the selected session's dialog |
|
||||
| `n` | Deny it, or **start a new session** when there is no dialog |
|
||||
| `p` | Send one line to the selected session without attaching |
|
||||
| `x` | Kill the selected session; `y` confirms, any other key cancels |
|
||||
| `/` | Search sessions, events and files |
|
||||
| `g` | Away digest: what happened while you were gone |
|
||||
| `?` | Help overlay |
|
||||
| `Esc` | Close whatever overlay is open |
|
||||
| `q` or `Ctrl+C` | Quit, restoring the screen you started with |
|
||||
|
||||
Inside the `p` composer and the `/` query: `←` `→` `Home` `End` `Delete`
|
||||
`Backspace` plus `Ctrl+A` / `Ctrl+E` / `Ctrl+U` / `Ctrl+W`, `Enter` to send or open,
|
||||
`Esc` (or `Ctrl+C`) to cancel. In the kill confirmation you retype the session name;
|
||||
anything else cancels. In the `n` pickers, type to filter, `Enter` chooses.
|
||||
|
||||
Verbs that need the server (`y`/`n`/`p`/`x`/`/`/`g`) say so in degraded mode
|
||||
instead of failing silently; `Enter` and `1-9` keep working.
|
||||
|
||||
### `p` sends exactly one line
|
||||
|
||||
The composer is a single line by design, ending in a carriage return: that is the
|
||||
input contract every Codeman path follows, because multi-line text breaks the
|
||||
agent's own composer. Pasted newlines become spaces rather than being rejected, so
|
||||
a paste cannot silently run a different command than the one you read.
|
||||
|
||||
## Answering approvals
|
||||
|
||||
This is the thing the terminal could not do before. Select a blocked session and:
|
||||
|
||||
- `y` approves.
|
||||
- `n` picks the parsed "No" option, or sends Esc when the dialog did not parse one.
|
||||
- A digit picks that numbered option, **but only a digit the dialog actually
|
||||
offers**. A digit with no matching option falls through to the list's own
|
||||
jump-and-attach binding, so it can never be typed at whatever has focus.
|
||||
|
||||
The answer goes through `POST /api/approvals/:id/answer`, which **re-captures the
|
||||
pane before it types anything**. If the dialog is no longer on screen (you answered
|
||||
it in tmux a moment ago, or the agent moved on), the server refuses with a 409 and
|
||||
the TUI says `that dialog is no longer on screen` rather than pressing a key into a
|
||||
live composer. The answer is scoped to the options the server parsed off the actual
|
||||
frame, never to a guess.
|
||||
|
||||
An idle prompt (`✋`) is not a dialog: there is nothing to approve, so `p` is the
|
||||
reply path and the footer says `p reply` instead of `p prompt`.
|
||||
|
||||
## Attaching
|
||||
|
||||
`Enter` suspends the dashboard (main screen back, cooked mode back) and hands the
|
||||
terminal to tmux with `stdio: inherit`. Colors, mouse and paste are tmux's, at full
|
||||
fidelity.
|
||||
|
||||
**Press `F1` to come back.** One key, no modifier to hold or release, nothing to
|
||||
type in a particular order. tmux's own way out is a chord — press the prefix, let
|
||||
go, then a letter — and beta testing showed that is genuinely hard to convey: the
|
||||
bar first named the wrong letter (tmux binds lowercase `d` to `detach-client` and
|
||||
capital `D` to `choose-client`), and once corrected it still failed for anyone who
|
||||
kept Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. So the TUI
|
||||
claims `F1` in tmux's prefix-less key table for the length of the attach and gives
|
||||
it back afterwards. The chord still works; it is simply not what you are told to
|
||||
press.
|
||||
|
||||
You do not have to remember any of it. For as long as the attach lasts the pane
|
||||
wears a bar across the top:
|
||||
|
||||
```
|
||||
1 w3-codeman-… 2 w4-codeman-… 3 testcase … alt+1-9 switch · F1 back to the codeman dashboard
|
||||
```
|
||||
|
||||
That is the **session strip**: the other sessions stay visible from inside a pane,
|
||||
numbered exactly as the dashboard numbers them, with the one you are in inverted.
|
||||
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. With
|
||||
more sessions than fit, the strip shows a window around the current one and marks
|
||||
each cut end with `…`; the way-out hint is measured first and always keeps its space.
|
||||
|
||||
Codeman keeps the status bar off on its panes (the web UI carries that information
|
||||
around the terminal instead), so the TUI turns it on for the attach and puts it back
|
||||
exactly as it was on detach, along with each window's size. Every session the strip
|
||||
can switch to is dressed and sized the same way, so switching is instant and lands
|
||||
in a pane that already fills your terminal.
|
||||
|
||||
Detaching leaves the agent running; typing `exit` or pressing `Ctrl+D` would end it,
|
||||
which is the difference the bar exists to make obvious. If an agent does exit, its
|
||||
pane stays as a corpse: the TUI refuses to attach to a dead pane and offers `r` to
|
||||
resume the conversation in a fresh one instead.
|
||||
|
||||
Three cases:
|
||||
|
||||
| Where you are | What happens |
|
||||
| --- | --- |
|
||||
| Not in tmux | `tmux -L codeman attach-session` |
|
||||
| Already in tmux on Codeman's socket | `switch-client`, so you do not nest |
|
||||
| In tmux on a **different** socket | Refused, with an explanation: detach from that tmux first, then run `codeman tui` again |
|
||||
|
||||
A direct-PTY session has no pane to attach to, and says so.
|
||||
|
||||
**`Enter` on a RECENT row resumes that conversation** instead: there is no pane to
|
||||
attach to, so the TUI creates a new claude session carrying the old transcript
|
||||
(`resumeSessionId`, exactly what the web UI's "Resume Conversation" list does), in
|
||||
the directory it originally ran in and under its old name, then attaches to it. It
|
||||
is claude-only, and a row with no working directory or no conversation id says why
|
||||
rather than resuming something else.
|
||||
|
||||
`x` never bulk-kills: it kills one session, only after you retype its name, never a
|
||||
history row, and never the session the TUI itself is running in.
|
||||
|
||||
## Over SSH, and on a phone
|
||||
|
||||
The TUI is an ordinary terminal program with no local dependencies beyond tmux, so
|
||||
`ssh box` then `codeman tui` works exactly like running it locally. There is no
|
||||
separate remote mode.
|
||||
|
||||
Below 72 columns (Termius, an iPhone in portrait) the preview pane is dropped and
|
||||
rows take two lines each, keeping the cursor, the live states and the
|
||||
answer/prompt/kill verbs. The switch is
|
||||
width-driven at draw time, so unfolding a foldable or resizing a window re-lays out
|
||||
immediately; there is no mode flag to set.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"The Codeman server rejected these credentials."** The server has
|
||||
`CODEMAN_PASSWORD` set. Export `CODEMAN_PASSWORD` (and `CODEMAN_USERNAME` if it is
|
||||
not `admin`), or put them in the data dir's `.env` (`~/.codeman/.env`), which is
|
||||
where `codeman attach` already reads them from.
|
||||
|
||||
**`server not running: attach only`** in a yellow banner. Nothing answered on the
|
||||
expected port, so the TUI fell back to enumerating tmux. You get names and attach;
|
||||
you do not get states, approvals or previews, because those only exist on the
|
||||
server. Start the server (`codeman web -d`, or `systemctl --user start codeman-web`)
|
||||
and the banner clears on its own: the TUI keeps re-probing.
|
||||
|
||||
**It found the wrong server, or none.** Discovery is instance-scoped. A beta
|
||||
instance (`CODEMAN_INSTANCE=beta`) has its own data dir *and* its own tmux socket,
|
||||
so its TUI sees only its own sessions. Set `CODEMAN_PORT` or `CODEMAN_API_URL`
|
||||
explicitly when you run more than one.
|
||||
|
||||
**"this terminal is already inside tmux on socket ..."** You are in a tmux session
|
||||
on a socket that is not Codeman's, so attaching would nest two multiplexers whose
|
||||
prefix keys collide. Detach from that tmux and run `codeman tui` from outside.
|
||||
|
||||
**Boxes and glyphs render as garbage.** The TUI picks a glyph tier from the
|
||||
environment: no `TERM` (or `dumb`), or a non-UTF-8 locale, gets the ASCII set
|
||||
(`[!] [w] [*] [-]`, `+`/`-`/`|` frames). Force it either way with
|
||||
`CODEMAN_TUI_GLYPHS=ascii|unicode|nerd`.
|
||||
|
||||
**Colors.** Standard `NO_COLOR` / `FORCE_COLOR` handling (chalk's, the same as the
|
||||
rest of the CLI). Under `NO_COLOR` the frame is cursor addressing and text only,
|
||||
and the preview's own colors are stripped too, so a session's output cannot repaint
|
||||
the dashboard.
|
||||
|
||||
**It refuses to open at all**, saying it needs an interactive terminal. stdout or
|
||||
stdin is not a TTY. That is the guard: use `codeman tui --list`.
|
||||
|
||||
## Related
|
||||
|
||||
- [`docs/tui-plan.md`](tui-plan.md): the design record. Why hand-rolled ANSI, why a
|
||||
client and not a second brain, and what is deliberately deferred.
|
||||
- [`docs/approvals-inbox-plan.md`](approvals-inbox-plan.md): where the parsed
|
||||
dialogs and the answer endpoint come from.
|
||||
- [`docs/remote-sessions.md`](remote-sessions.md): remote-SSH cases, which the TUI
|
||||
lists like any other session.
|
||||
@@ -88,7 +88,7 @@ would. That indirection buys:
|
||||
- **Real scrollback.** History is held by tmux, so reconnecting replays what happened while
|
||||
you were gone instead of starting from blank.
|
||||
- **Attach from anywhere else.** The same session is reachable from a terminal over SSH
|
||||
with the `sc` chooser, or plain `tmux -L codeman attach`.
|
||||
with `codeman tui`, or plain `tmux -L codeman attach`.
|
||||
- **Secrets off the command line.** Environment overrides are injected with socket-scoped
|
||||
`tmux setenv` rather than being visible in the spawn command.
|
||||
|
||||
|
||||
+4
-2
@@ -73,8 +73,10 @@ features are Claude-only; [Agent CLIs](Agent-CLIs) lists exactly which.
|
||||
|
||||
### Can I attach to a session from a terminal instead of the browser?
|
||||
|
||||
Yes. `sc` is an interactive chooser (`sc 2` attaches directly, `sc -l` lists), or use tmux
|
||||
directly on the `codeman` socket. Detach with `Ctrl+A D`.
|
||||
Yes. `codeman tui` is a full-screen dashboard of your sessions, with the same
|
||||
NEEDS YOU / WORKING / IDLE grouping the web UI uses. `codeman tui --list` prints the
|
||||
numbered list and exits, and `codeman tui 2` attaches straight to session 2. `Enter`
|
||||
attaches, `F1` comes back. You can also use tmux directly on the `codeman` socket.
|
||||
|
||||
## Running unattended
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ Shell and external CLI sessions accept `idle`, `working`, and `exit`.
|
||||
|
||||
## SSE
|
||||
|
||||
`GET /api/events` is the live event stream. 155 event names, kept in sync between server and
|
||||
`GET /api/events` is the live event stream. 156 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
|
||||
|
||||
@@ -179,16 +179,19 @@ the same IP, which matters because all tunnel traffic arrives from one loopback
|
||||
|
||||
## Terminal alternatives
|
||||
|
||||
You do not have to use a browser. `sc` is a thumb-friendly session chooser for SSH clients
|
||||
like Termius or Blink:
|
||||
You do not have to use a browser. `codeman tui` is a full-screen session dashboard that
|
||||
works well in SSH clients like Termius or Blink:
|
||||
|
||||
```bash
|
||||
sc # interactive chooser
|
||||
sc 2 # attach to session 2
|
||||
sc -l # list
|
||||
codeman tui # the dashboard
|
||||
codeman tui 2 # attach straight to session 2
|
||||
codeman tui --list # numbered list, then exit
|
||||
```
|
||||
|
||||
Detach with `Ctrl+A D`. The sessions are the same ones the dashboard shows.
|
||||
`Enter` attaches into the pane and `F1` comes back. Under 72 columns it drops the preview
|
||||
and becomes a single-column list, so it stays usable on a phone. The sessions are the same
|
||||
ones the dashboard shows. See [docs/tui.md](https://github.com/Ark0N/Codeman/blob/master/docs/tui.md)
|
||||
for the full guide.
|
||||
|
||||
## Common problems
|
||||
|
||||
|
||||
@@ -133,8 +133,10 @@ TUIs render correctly.
|
||||
|
||||
Worth knowing:
|
||||
|
||||
- **Scrollback.** The first time you open a session, Codeman pulls the entire tmux
|
||||
scrollback, not just the recent tail. Scrolling to the very top pulls again on demand.
|
||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||
and automatic output recovery stay within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||
always local scrollback. Other CLIs scroll locally.
|
||||
|
||||
+353
-27
@@ -125,6 +125,22 @@ PI_SEARCH_PATHS=(
|
||||
"$HOME/bin/pi"
|
||||
)
|
||||
|
||||
# DeepSeek Harness search paths (from src/utils/deepseek-cli-resolver.ts)
|
||||
DSH_SEARCH_PATHS=(
|
||||
"$HOME/.local/bin/dsh"
|
||||
"/usr/local/bin/dsh"
|
||||
"$HOME/.npm-global/bin/dsh"
|
||||
"$HOME/bin/dsh"
|
||||
)
|
||||
|
||||
# Grok CLI search paths (from src/utils/grok-cli-resolver.ts)
|
||||
GROK_SEARCH_PATHS=(
|
||||
"$HOME/.grok/bin/grok"
|
||||
"$HOME/.local/bin/grok"
|
||||
"/usr/local/bin/grok"
|
||||
"$HOME/bin/grok"
|
||||
)
|
||||
|
||||
# Antigravity CLI search paths (from src/utils/antigravity-cli-resolver.ts)
|
||||
ANTIGRAVITY_SEARCH_PATHS=(
|
||||
"$HOME/.local/bin/agy"
|
||||
@@ -133,6 +149,17 @@ ANTIGRAVITY_SEARCH_PATHS=(
|
||||
"$HOME/bin/agy"
|
||||
)
|
||||
|
||||
# OMP CLI search paths (from src/utils/omp-cli-resolver.ts's OMP_SEARCH_DIRS —
|
||||
# ~/.local/bin leads, omp.sh's installer target; ~/.omp/bin is a fallback only)
|
||||
OMP_SEARCH_PATHS=(
|
||||
"$HOME/.local/bin/omp"
|
||||
"$HOME/.omp/bin/omp"
|
||||
"/usr/local/bin/omp"
|
||||
"$HOME/.bun/bin/omp"
|
||||
"$HOME/.npm-global/bin/omp"
|
||||
"$HOME/bin/omp"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Color Output
|
||||
# ============================================================================
|
||||
@@ -229,7 +256,11 @@ print_security_notice() {
|
||||
echo -e " ${YELLOW}${BOLD}Security:${NC}"
|
||||
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
|
||||
echo -e " To reach it from another device, do ONE of:"
|
||||
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
|
||||
if check_tailscale; then
|
||||
echo -e " ${CYAN}•${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC} ${DIM}(Tailscale is installed here; HTTPS, recommended)${NC}, or"
|
||||
else
|
||||
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
|
||||
fi
|
||||
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
|
||||
echo -e " A non-loopback bind without a password still starts, but warns loudly."
|
||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||
@@ -396,6 +427,27 @@ check_tmux() {
|
||||
command -v tmux &>/dev/null
|
||||
}
|
||||
|
||||
# node-pty ships prebuilt binaries for darwin and win32 ONLY, so on Linux it is
|
||||
# always compiled from source during `npm install`. Without a toolchain that
|
||||
# fails deep inside node-gyp with `not found: make`, which reads like an npm bug
|
||||
# rather than a missing system package (issue: fresh Ubuntu 24 server install).
|
||||
# So the toolchain is checked up front, exactly like git and tmux.
|
||||
#
|
||||
# Returns a human-readable list of what is missing, empty when all present.
|
||||
missing_build_tools() {
|
||||
local missing=""
|
||||
command -v make &>/dev/null || missing="make"
|
||||
if ! command -v c++ &>/dev/null && ! command -v g++ &>/dev/null && ! command -v clang++ &>/dev/null; then
|
||||
missing="${missing:+$missing, }a C++ compiler (g++)"
|
||||
fi
|
||||
command -v python3 &>/dev/null || missing="${missing:+$missing, }python3"
|
||||
printf '%s' "$missing"
|
||||
}
|
||||
|
||||
check_build_tools() {
|
||||
[[ -z "$(missing_build_tools)" ]]
|
||||
}
|
||||
|
||||
check_claude() {
|
||||
# Check PATH first
|
||||
if command -v claude &>/dev/null; then
|
||||
@@ -569,6 +621,119 @@ get_pi_path() {
|
||||
done
|
||||
}
|
||||
|
||||
# `grok` has known squatters too (the unrelated @vibe-kit/grok-cli), so the
|
||||
# server-side resolver additionally probes `grok --version`. Detection here only
|
||||
# feeds the "you have no AI CLI" hint, so a plain executable test is enough.
|
||||
check_grok() {
|
||||
if command -v grok &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for path in "${GROK_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh`
|
||||
# (dancer's shell). The server-side resolver settles it by demanding the
|
||||
# harness's own help banner; detection here only feeds the "you have no AI CLI"
|
||||
# hint, so the same banner grep is enough — but unlike every sibling probe it
|
||||
# EXECUTES the candidate, so it must be bounded. </dev/null is load-bearing
|
||||
# twice over: a foreign binary that blocks on stdin would hang the install, and
|
||||
# under `curl | bash` a child that reads stdin EATS THE REST OF THIS SCRIPT.
|
||||
# The timeout (where coreutils ships one; stock macOS has none) bounds a binary
|
||||
# that ignores EOF, mirroring the server resolver's own EXEC_TIMEOUT_MS.
|
||||
dsh_banner_probe() {
|
||||
local runner=()
|
||||
if command -v timeout &>/dev/null; then runner=(timeout 5); fi
|
||||
"${runner[@]}" "$1" --help </dev/null 2>/dev/null | grep -qi "DeepSeek Harness"
|
||||
}
|
||||
|
||||
# Resolved ONCE and memoized: the probe executes a possibly-foreign binary, and
|
||||
# the check/get/reminder call sites together used to re-run the whole scan many
|
||||
# times per install.
|
||||
DSH_RESOLVE_DONE=""
|
||||
DSH_RESOLVED_PATH=""
|
||||
resolve_dsh() {
|
||||
[[ -n "$DSH_RESOLVE_DONE" ]] && return 0
|
||||
DSH_RESOLVE_DONE=1
|
||||
local candidate path
|
||||
if command -v dsh &>/dev/null; then
|
||||
candidate="$(command -v dsh)"
|
||||
if dsh_banner_probe "$candidate"; then
|
||||
DSH_RESOLVED_PATH="$candidate"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
for path in "${DSH_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]] && dsh_banner_probe "$path"; then
|
||||
DSH_RESOLVED_PATH="$path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
check_dsh() {
|
||||
resolve_dsh
|
||||
[[ -n "$DSH_RESOLVED_PATH" ]]
|
||||
}
|
||||
|
||||
get_dsh_path() {
|
||||
resolve_dsh
|
||||
echo "$DSH_RESOLVED_PATH"
|
||||
}
|
||||
|
||||
get_grok_path() {
|
||||
if command -v grok &>/dev/null; then
|
||||
command -v grok
|
||||
return
|
||||
fi
|
||||
|
||||
for path in "${GROK_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
echo "$path"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# `omp` is a short name too, so like grok/pi the server-side resolver
|
||||
# additionally probes `omp --version`. Detection here only feeds the
|
||||
# "you have no AI CLI" hint, so a plain executable test is enough.
|
||||
check_omp() {
|
||||
if command -v omp &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for path in "${OMP_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
get_omp_path() {
|
||||
if command -v omp &>/dev/null; then
|
||||
command -v omp
|
||||
return
|
||||
fi
|
||||
|
||||
for path in "${OMP_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
echo "$path"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_cloudflared() {
|
||||
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
|
||||
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
|
||||
@@ -836,6 +1001,50 @@ install_git_suse() {
|
||||
run_as_root zypper install -y git
|
||||
}
|
||||
|
||||
# Build toolchain for node-pty's source compile (see missing_build_tools).
|
||||
install_buildtools_debian() {
|
||||
info "Installing build tools via apt (build-essential, python3)..."
|
||||
ensure_sudo
|
||||
run_as_root apt-get update -qq
|
||||
run_as_root apt-get install -y -qq build-essential python3
|
||||
}
|
||||
|
||||
install_buildtools_fedora() {
|
||||
info "Installing build tools (gcc, gcc-c++, make, python3)..."
|
||||
ensure_sudo
|
||||
if command -v dnf &>/dev/null; then
|
||||
run_as_root dnf install -y gcc gcc-c++ make python3
|
||||
else
|
||||
run_as_root yum install -y gcc gcc-c++ make python3
|
||||
fi
|
||||
}
|
||||
|
||||
install_buildtools_arch() {
|
||||
info "Installing build tools via pacman (base-devel, python)..."
|
||||
ensure_sudo
|
||||
run_as_root pacman -Sy --noconfirm base-devel python
|
||||
}
|
||||
|
||||
install_buildtools_alpine() {
|
||||
info "Installing build tools via apk (build-base, python3)..."
|
||||
ensure_sudo
|
||||
run_as_root apk add --no-cache build-base python3
|
||||
}
|
||||
|
||||
install_buildtools_suse() {
|
||||
info "Installing build tools via zypper..."
|
||||
ensure_sudo
|
||||
run_as_root zypper install -y gcc gcc-c++ make python3
|
||||
}
|
||||
|
||||
install_buildtools_macos() {
|
||||
# macOS normally never gets here: node-pty ships darwin prebuilds. Only a
|
||||
# forced source build needs a compiler, and Xcode CLT is its only supplier.
|
||||
info "Requesting Xcode Command Line Tools..."
|
||||
xcode-select --install 2>/dev/null || true
|
||||
die "Finish the Xcode Command Line Tools install in the dialog, then re-run this installer."
|
||||
}
|
||||
|
||||
install_cloudflared_macos() {
|
||||
info "Installing cloudflared via Homebrew..."
|
||||
ensure_homebrew
|
||||
@@ -1072,21 +1281,28 @@ add_to_path() {
|
||||
success "Added to $profile"
|
||||
}
|
||||
|
||||
setup_sc_alias() {
|
||||
# The `sc` bash chooser was retired in favour of `codeman tui`, which reaches
|
||||
# sessions 10+, carries the server's real states and leaves an attach with one
|
||||
# key. Older installers wrote this alias, so take it back out.
|
||||
#
|
||||
# Marker-owned on purpose: it matches the exact line WE wrote, so a user's own
|
||||
# `alias sc=` for something entirely different is never touched. The rewrite
|
||||
# goes through `cat >` rather than `mv` so the profile keeps its own mode and
|
||||
# ownership.
|
||||
remove_sc_alias() {
|
||||
local profile
|
||||
profile=$(detect_shell_profile)
|
||||
[[ -f "$profile" ]] || return 0
|
||||
grep -qE "^alias sc='tmux-chooser'\$" "$profile" 2>/dev/null || return 0
|
||||
|
||||
# Check if alias already exists
|
||||
if [[ -f "$profile" ]] && grep -qE "^alias sc=" "$profile" 2>/dev/null; then
|
||||
info "Alias 'sc' already configured in $profile"
|
||||
return 0
|
||||
local tmp
|
||||
tmp=$(mktemp 2>/dev/null) || return 0
|
||||
if sed -e "/^alias sc='tmux-chooser'\$/d" \
|
||||
-e '/^# Codeman tmux session shortcut$/d' "$profile" > "$tmp" 2>/dev/null; then
|
||||
cat "$tmp" > "$profile"
|
||||
info "Removed the retired 'sc' alias from $profile (use: codeman tui)"
|
||||
fi
|
||||
|
||||
echo "" >> "$profile"
|
||||
echo "# Codeman tmux session shortcut" >> "$profile"
|
||||
echo "alias sc='tmux-chooser'" >> "$profile"
|
||||
|
||||
info "Added 'sc' alias for tmux-chooser"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
@@ -1704,6 +1920,41 @@ setup_tailscale_access() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# A loopback install with Tailscale already connected but nothing fronting
|
||||
# Codeman is one command away from working remote access — and that is exactly
|
||||
# where a user lands when the first install died BEFORE the network-access
|
||||
# prompt (it runs after the build, so any build failure costs the network step
|
||||
# too) or when they finished a broken build by hand instead of re-running the
|
||||
# installer. Detect that state on re-run and offer the retrofit, rather than
|
||||
# leaving them to discover `install.sh tailscale` on their own. Never nags a
|
||||
# deliberate network bind, and never nags once a serve mapping already exists.
|
||||
maybe_offer_tailscale_repair() {
|
||||
# A non-loopback bind already has network access; leave that choice alone.
|
||||
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
check_tailscale || return 0
|
||||
command -v node &>/dev/null || return 0
|
||||
[[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0
|
||||
# Already fronting Codeman: nothing to repair.
|
||||
[[ -z "$(detect_tailscale_serve_url)" ]] || return 0
|
||||
|
||||
echo ""
|
||||
info "Tailscale is connected here, but no serve mapping fronts Codeman yet."
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
|
||||
echo -e " ${DIM}Enable HTTPS access from your tailnet with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}"
|
||||
return 0
|
||||
fi
|
||||
if ! prompt_yes_no "Set up Tailscale HTTPS access now? (your tailnet is the login; no password needed)" "y"; then
|
||||
echo -e " ${DIM}Any time later:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}"
|
||||
return 0
|
||||
fi
|
||||
if setup_tailscale_access; then
|
||||
verify_tailscale_access || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# `install.sh tailscale`: retrofit Tailscale access onto an existing install
|
||||
# (also the target of every "set it up later" hint above).
|
||||
setup_tailscale_subcommand() {
|
||||
@@ -1956,6 +2207,29 @@ setup_tunnel_service() {
|
||||
# Installation Helpers
|
||||
# ============================================================================
|
||||
|
||||
# npm install with an actionable message for the failure that actually happens
|
||||
# on a fresh Linux box: no toolchain, so node-pty cannot compile.
|
||||
npm_install_deps() {
|
||||
if npm install --quiet --no-fund --no-audit 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
if npm install --no-fund --no-audit; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
error "npm install failed."
|
||||
if [[ "$(detect_os)" == "linux" ]] && ! check_build_tools; then
|
||||
error "Missing native build tools: $(missing_build_tools)"
|
||||
error "node-pty has no Linux prebuilds, so it must compile from source."
|
||||
error "Install them and re-run this installer:"
|
||||
error " Debian/Ubuntu: sudo apt-get install -y build-essential python3"
|
||||
error " Fedora/RHEL: sudo dnf install -y gcc gcc-c++ make python3"
|
||||
error " Arch: sudo pacman -S --noconfirm base-devel python"
|
||||
error " Alpine: sudo apk add build-base python3"
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
install_dependency() {
|
||||
local dep_name="$1"
|
||||
local os="$2"
|
||||
@@ -2069,6 +2343,31 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Native build toolchain. node-pty compiles from source on Linux, so this is
|
||||
# a hard requirement there, not a nicety.
|
||||
if [[ "$os" == "linux" ]]; then
|
||||
info "Checking build tools (node-pty compiles from source on Linux)..."
|
||||
local missing_tools
|
||||
missing_tools="$(missing_build_tools)"
|
||||
if [[ -z "$missing_tools" ]]; then
|
||||
success "Build tools are installed"
|
||||
else
|
||||
warn "Missing build tools: $missing_tools"
|
||||
headless_guard "install build tools (system package via sudo)"
|
||||
if prompt_yes_no "Install the build tools now?"; then
|
||||
install_dependency "buildtools" "$os" "$distro"
|
||||
hash -r 2>/dev/null || true
|
||||
missing_tools="$(missing_build_tools)"
|
||||
if [[ -n "$missing_tools" ]]; then
|
||||
die "Build tools still missing after install: $missing_tools. Install them manually and re-run."
|
||||
fi
|
||||
success "Build tools installed"
|
||||
else
|
||||
die "A build toolchain (make, g++, python3) is required: node-pty has no Linux prebuilds and compiles from source."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi)
|
||||
local has_claude=false
|
||||
local has_opencode=false
|
||||
@@ -2076,6 +2375,9 @@ main() {
|
||||
local has_gemini=false
|
||||
local has_antigravity=false
|
||||
local has_pi=false
|
||||
local has_grok=false
|
||||
local has_dsh=false
|
||||
local has_omp=false
|
||||
|
||||
info "Checking AI CLI tools..."
|
||||
if check_claude; then
|
||||
@@ -2102,17 +2404,29 @@ main() {
|
||||
has_pi=true
|
||||
success "Pi CLI found at $(get_pi_path)"
|
||||
fi
|
||||
if check_grok; then
|
||||
has_grok=true
|
||||
success "Grok CLI found at $(get_grok_path)"
|
||||
fi
|
||||
if check_dsh; then
|
||||
has_dsh=true
|
||||
success "DeepSeek Harness found at $(get_dsh_path)"
|
||||
fi
|
||||
if check_omp; then
|
||||
has_omp=true
|
||||
success "OMP CLI found at $(get_omp_path)"
|
||||
fi
|
||||
|
||||
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" ]]; then
|
||||
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" && "$has_omp" == "false" ]]; then
|
||||
echo ""
|
||||
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi."
|
||||
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP."
|
||||
headless_guard "install an AI CLI (curl | bash from its vendor)"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
|
||||
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
|
||||
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
|
||||
echo -e " ${CYAN}3)${NC} Both"
|
||||
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity or Pi)"
|
||||
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness or OMP)"
|
||||
echo ""
|
||||
|
||||
local cli_choice=""
|
||||
@@ -2160,6 +2474,7 @@ main() {
|
||||
info "Install one later, e.g.: npm install -g @openai/codex (Codex)"
|
||||
info " or: curl -fsSL https://antigravity.google/cli/install.sh | bash (Antigravity)"
|
||||
info " or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)"
|
||||
info " or: curl -fsSL https://x.ai/cli/install.sh | bash (Grok)"
|
||||
elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
|
||||
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
|
||||
fi
|
||||
@@ -2225,7 +2540,7 @@ main() {
|
||||
# ========================================================================
|
||||
|
||||
info "Installing dependencies..."
|
||||
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
|
||||
npm_install_deps
|
||||
|
||||
info "Building..."
|
||||
npm run build --quiet 2>/dev/null || npm run build
|
||||
@@ -2243,13 +2558,14 @@ main() {
|
||||
ln -sf "$INSTALL_DIR/dist/index.js" "$symlink_dir/codeman"
|
||||
info "Created symlink: $symlink_dir/codeman"
|
||||
|
||||
# Install tmux-chooser as 'tmux-chooser' command
|
||||
if [[ -f "$INSTALL_DIR/scripts/tmux-chooser.sh" ]]; then
|
||||
ln -sf "$INSTALL_DIR/scripts/tmux-chooser.sh" "$symlink_dir/tmux-chooser"
|
||||
info "Created symlink: $symlink_dir/tmux-chooser"
|
||||
# Add 'sc' alias for quick access
|
||||
setup_sc_alias
|
||||
# tmux-chooser/`sc` is retired; `codeman tui` replaces it. Sweep up what
|
||||
# an older installer left behind, so an update does not leave a symlink
|
||||
# pointing at a script this version no longer ships.
|
||||
if [[ -L "$symlink_dir/tmux-chooser" ]]; then
|
||||
rm -f "$symlink_dir/tmux-chooser"
|
||||
info "Removed the retired tmux-chooser symlink (use: codeman tui)"
|
||||
fi
|
||||
remove_sc_alias
|
||||
|
||||
# Add ~/.local/bin to PATH if not already there
|
||||
if [[ ":$PATH:" != *":$symlink_dir:"* ]]; then
|
||||
@@ -2450,23 +2766,28 @@ main() {
|
||||
|
||||
echo -e " ${BOLD}Mobile Access (Termius/SSH):${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}sc${NC} # Interactive tmux session chooser"
|
||||
echo -e " ${CYAN}sc 2${NC} # Quick attach to session 2"
|
||||
echo -e " ${CYAN}sc -h${NC} # Help"
|
||||
echo -e " ${CYAN}codeman tui${NC} # Full-screen session dashboard"
|
||||
echo -e " ${CYAN}codeman tui 2${NC} # Attach straight to session 2"
|
||||
echo -e " ${CYAN}codeman tui -l${NC} # Numbered list, then exit"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Documentation:${NC}"
|
||||
echo -e " https://github.com/Ark0N/Codeman"
|
||||
echo ""
|
||||
|
||||
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi; then
|
||||
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh && ! check_omp; then
|
||||
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
|
||||
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
|
||||
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
|
||||
echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex"
|
||||
echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity"
|
||||
echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi"
|
||||
echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok"
|
||||
echo -e " ${CYAN}curl -fsSL https://omp.sh/install | sh${NC} # OMP"
|
||||
echo ""
|
||||
echo -e " DeepSeek Harness has no vendor one-liner — install it from within Codeman"
|
||||
echo -e " once the server is up (Run dropdown → Install DeepSeek Profile, or see"
|
||||
echo -e " docs/deepseek-integration.md)."
|
||||
fi
|
||||
|
||||
# Security notice — last informational block so it stays visible (when not
|
||||
@@ -2519,7 +2840,7 @@ update() {
|
||||
|
||||
git fetch --quiet origin
|
||||
git reset --hard "origin/$BRANCH" --quiet
|
||||
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
|
||||
npm_install_deps
|
||||
npm run build --quiet 2>/dev/null || npm run build
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
@@ -2556,6 +2877,10 @@ update() {
|
||||
BIND_ACK="$EXISTING_ACK"
|
||||
fi
|
||||
|
||||
# An update is the only place a half-configured install gets a second
|
||||
# chance at remote access; the fresh-install path asks outright.
|
||||
maybe_offer_tailscale_repair
|
||||
|
||||
print_security_notice
|
||||
}
|
||||
|
||||
@@ -2620,6 +2945,7 @@ uninstall() {
|
||||
rm -f "$symlink_dir/tmux-chooser"
|
||||
success "Removed symlink: $symlink_dir/tmux-chooser"
|
||||
fi
|
||||
remove_sc_alias
|
||||
|
||||
# Remove install directory
|
||||
if [[ -d "$INSTALL_DIR" ]]; then
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.20.0",
|
||||
"version": "1.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.20.0",
|
||||
"version": "1.24.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.20.0",
|
||||
"version": "1.24.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -62,6 +62,8 @@
|
||||
"codex",
|
||||
"antigravity",
|
||||
"pi",
|
||||
"grok",
|
||||
"deepseek",
|
||||
"gemini-cli",
|
||||
"ai-agents",
|
||||
"agent",
|
||||
|
||||
@@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
||||
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
||||
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
|
||||
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
|
||||
@@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'input-cjk.js',
|
||||
'sanitize-html.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
'terminal-ui.js',
|
||||
'respawn-ui.js',
|
||||
'ralph-panel.js',
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Codeman Sessions - Mobile-friendly Tmux Session Chooser
|
||||
# Optimized for iPhone/Termius (portrait ~45 chars, landscape ~95 chars)
|
||||
# ============================================================================
|
||||
#
|
||||
# Design principles:
|
||||
# - Single-digit selection (1-9) for fast thumb typing
|
||||
# - Compact display, no wasted space
|
||||
# - Color-coded status for quick scanning
|
||||
# - Names pulled from Codeman state.json
|
||||
# - Minimal keystrokes to attach
|
||||
#
|
||||
# Usage:
|
||||
# tmux-chooser # Interactive chooser
|
||||
# tmux-chooser 1 # Quick attach to session 1
|
||||
# tmux-chooser -l # List only (non-interactive)
|
||||
# tmux-chooser -h # Help
|
||||
#
|
||||
# Alias: alias sc='tmux-chooser'
|
||||
# Then: sc (interactive)
|
||||
# sc 2 (attach session 2)
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
CODEMAN_STATE="$HOME/.codeman/state.json"
|
||||
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
|
||||
|
||||
# Dedicated tmux socket all Codeman sessions live on. MUST match
|
||||
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
|
||||
# otherwise list-sessions would enumerate the user's default tmux server
|
||||
# (missing the real Codeman sessions, surfacing unrelated ones).
|
||||
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
|
||||
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
|
||||
|
||||
|
||||
# iPhone 17 Pro portrait width (conservative)
|
||||
MAX_WIDTH=44
|
||||
MAX_NAME_LEN=28
|
||||
|
||||
# Page size for pagination (leave room for header/footer)
|
||||
PAGE_SIZE=7
|
||||
|
||||
# Auto-refresh timeout (seconds) - 0 to disable
|
||||
AUTO_REFRESH=60
|
||||
|
||||
# ============================================================================
|
||||
# Icon Detection (Nerd Fonts vs ASCII)
|
||||
# ============================================================================
|
||||
|
||||
detect_icons() {
|
||||
if [[ "$TERM_PROGRAM" == "iTerm"* ]] || \
|
||||
[[ "$TERM" == "xterm-kitty" ]] || \
|
||||
[[ -n "$WEZTERM_PANE" ]] || \
|
||||
[[ "$LC_TERMINAL" == "iTerm2" ]]; then
|
||||
ICON_SESSION=""
|
||||
ICON_ATTACHED="●"
|
||||
ICON_DETACHED="○"
|
||||
ICON_UNKNOWN="◌"
|
||||
else
|
||||
ICON_SESSION="[T]"
|
||||
ICON_ATTACHED="*"
|
||||
ICON_DETACHED="-"
|
||||
ICON_UNKNOWN="?"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_icons
|
||||
|
||||
# ============================================================================
|
||||
# Colors - ANSI 256 for better Termius compatibility
|
||||
# ============================================================================
|
||||
|
||||
R='\033[0m' # Reset
|
||||
B='\033[1m' # Bold
|
||||
D='\033[2m' # Dim
|
||||
GREEN='\033[38;5;82m'
|
||||
YELLOW='\033[38;5;220m'
|
||||
BLUE='\033[38;5;75m'
|
||||
CYAN='\033[38;5;87m'
|
||||
RED='\033[38;5;203m'
|
||||
GRAY='\033[38;5;245m'
|
||||
WHITE='\033[38;5;255m'
|
||||
BG_SEL='\033[48;5;236m'
|
||||
|
||||
# ============================================================================
|
||||
# Utilities
|
||||
# ============================================================================
|
||||
|
||||
truncate() {
|
||||
local str="$1"
|
||||
local max="$2"
|
||||
local len=${#str}
|
||||
|
||||
if [ "$len" -le "$max" ]; then
|
||||
echo "$str"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$str" == *"/"* ]]; then
|
||||
echo "..${str: -$((max-2))}"
|
||||
else
|
||||
echo "${str:0:$((max-1))}…"
|
||||
fi
|
||||
}
|
||||
|
||||
find_full_session_id() {
|
||||
local short_id="$1"
|
||||
|
||||
if [ -f "$CODEMAN_STATE" ]; then
|
||||
local full_id
|
||||
full_id=$(jq -r --arg short "$short_id" '
|
||||
.sessions | keys[] | select(startswith($short))
|
||||
' "$CODEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$full_id" ]; then
|
||||
echo "$full_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$CODEMAN_SESSIONS" ]; then
|
||||
local full_id
|
||||
full_id=$(jq -r --arg short "$short_id" '
|
||||
.[] | select(.sessionId | startswith($short)) | .sessionId
|
||||
' "$CODEMAN_SESSIONS" 2>/dev/null | head -1)
|
||||
if [ -n "$full_id" ]; then
|
||||
echo "$full_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$short_id"
|
||||
}
|
||||
|
||||
get_session_name() {
|
||||
local session_id="$1"
|
||||
local name=""
|
||||
local workdir=""
|
||||
|
||||
if [ -f "$CODEMAN_SESSIONS" ]; then
|
||||
local result
|
||||
result=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | "\(.name // "")\t\(.workingDir // "")"
|
||||
' "$CODEMAN_SESSIONS" 2>/dev/null | head -1)
|
||||
if [ -n "$result" ]; then
|
||||
name="${result%% *}"
|
||||
workdir="${result#* }"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$name" ] && [ -f "$CODEMAN_STATE" ]; then
|
||||
local result
|
||||
result=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) | "\(.value.name // "")\t\(.value.workingDir // "")"
|
||||
' "$CODEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$result" ]; then
|
||||
name="${result%% *}"
|
||||
[ -z "$workdir" ] && workdir="${result#* }"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$name" ]; then
|
||||
echo "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$workdir" ]; then
|
||||
echo "${workdir##*/}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "${session_id:0:8}"
|
||||
}
|
||||
|
||||
get_working_dir() {
|
||||
local session_id="$1"
|
||||
|
||||
if [ -f "$CODEMAN_SESSIONS" ]; then
|
||||
local dir
|
||||
dir=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | .workingDir // empty
|
||||
' "$CODEMAN_SESSIONS" 2>/dev/null | head -1)
|
||||
if [ -n "$dir" ] && [ "$dir" != "null" ]; then
|
||||
echo "${dir/#$HOME/~}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$CODEMAN_STATE" ]; then
|
||||
local dir
|
||||
dir=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) | .value.workingDir // empty
|
||||
' "$CODEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$dir" ] && [ "$dir" != "null" ]; then
|
||||
echo "${dir/#$HOME/~}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
get_tokens() {
|
||||
local session_id="$1"
|
||||
|
||||
if [ -f "$CODEMAN_STATE" ]; then
|
||||
local tokens
|
||||
tokens=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) |
|
||||
((.value.inputTokens // 0) + (.value.outputTokens // 0))
|
||||
' "$CODEMAN_STATE" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$tokens" ] && [ "$tokens" != "null" ] && [ "$tokens" -gt 0 ] 2>/dev/null; then
|
||||
if [ "$tokens" -gt 1000 ]; then
|
||||
echo "$((tokens / 1000))k"
|
||||
else
|
||||
echo "${tokens}"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
get_respawn_status() {
|
||||
local session_id="$1"
|
||||
|
||||
if [ -f "$CODEMAN_SESSIONS" ]; then
|
||||
local respawn_enabled
|
||||
respawn_enabled=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | .respawnConfig.enabled // false
|
||||
' "$CODEMAN_SESSIONS" 2>/dev/null | head -1)
|
||||
|
||||
if [ "$respawn_enabled" = "true" ]; then
|
||||
echo "R"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
check_deps() {
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo -e "${YELLOW}Note: Install jq for session names${R}"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Tmux Session Parser
|
||||
# ============================================================================
|
||||
|
||||
declare -a SESSION_PIDS
|
||||
declare -a MUX_NAMES
|
||||
declare -a SESSION_STATES
|
||||
declare -a SESSION_IDS
|
||||
declare -a DISPLAY_NAMES
|
||||
declare -a WORKING_DIRS
|
||||
declare -a TOKEN_COUNTS
|
||||
declare -a RESPAWN_STATUS
|
||||
|
||||
parse_sessions() {
|
||||
SESSION_PIDS=()
|
||||
MUX_NAMES=()
|
||||
SESSION_STATES=()
|
||||
SESSION_IDS=()
|
||||
DISPLAY_NAMES=()
|
||||
WORKING_DIRS=()
|
||||
TOKEN_COUNTS=()
|
||||
RESPAWN_STATUS=()
|
||||
|
||||
local i=0
|
||||
|
||||
# Parse tmux list-sessions output
|
||||
while IFS= read -r line; do
|
||||
local session_name="${line%%:*}"
|
||||
|
||||
# Only show codeman sessions
|
||||
if [[ "$session_name" != codeman-* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if attached
|
||||
local state="Detached"
|
||||
if [[ "$line" == *"(attached)"* ]]; then
|
||||
state="Attached"
|
||||
fi
|
||||
|
||||
# Get PID from tmux
|
||||
local pid
|
||||
pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
|
||||
|
||||
SESSION_PIDS+=("$pid")
|
||||
MUX_NAMES+=("$session_name")
|
||||
SESSION_STATES+=("$state")
|
||||
|
||||
# Extract session ID from codeman session name
|
||||
local session_id=""
|
||||
local cm_regex='^codeman-(.+)$'
|
||||
if [[ "$session_name" =~ $cm_regex ]]; then
|
||||
session_id="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
SESSION_IDS+=("$session_id")
|
||||
|
||||
# Get display name and metadata
|
||||
if [ -n "$session_id" ]; then
|
||||
DISPLAY_NAMES+=("$(get_session_name "$session_id")")
|
||||
WORKING_DIRS+=("$(get_working_dir "$session_id")")
|
||||
TOKEN_COUNTS+=("$(get_tokens "$session_id")")
|
||||
RESPAWN_STATUS+=("$(get_respawn_status "$session_id")")
|
||||
else
|
||||
DISPLAY_NAMES+=("$session_name")
|
||||
WORKING_DIRS+=("")
|
||||
TOKEN_COUNTS+=("")
|
||||
RESPAWN_STATUS+=("")
|
||||
fi
|
||||
|
||||
i=$((i + 1))
|
||||
done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Display Functions
|
||||
# ============================================================================
|
||||
|
||||
clear_screen() {
|
||||
printf '\033[2J\033[H'
|
||||
}
|
||||
|
||||
print_header() {
|
||||
local count=${#SESSION_PIDS[@]}
|
||||
echo -e "${B}${CYAN}Codeman Sessions${R} ${D}($count)${R}"
|
||||
echo -e "${D}$(printf '%.0s─' {1..32})${R}"
|
||||
}
|
||||
|
||||
print_entry() {
|
||||
local idx="$1"
|
||||
local num=$((idx + 1))
|
||||
local name="${DISPLAY_NAMES[$idx]}"
|
||||
local state="${SESSION_STATES[$idx]}"
|
||||
local dir="${WORKING_DIRS[$idx]}"
|
||||
local tokens="${TOKEN_COUNTS[$idx]}"
|
||||
local respawn="${RESPAWN_STATUS[$idx]}"
|
||||
|
||||
local name_max=$MAX_NAME_LEN
|
||||
[ -n "$respawn" ] && name_max=$((name_max - 2))
|
||||
[ -n "$tokens" ] && name_max=$((name_max - 4))
|
||||
name=$(truncate "$name" $name_max)
|
||||
|
||||
local status_icon status_color
|
||||
if [[ "$state" == *"Attached"* ]]; then
|
||||
status_icon="$ICON_ATTACHED"
|
||||
status_color="$GREEN"
|
||||
elif [[ "$state" == *"Detached"* ]]; then
|
||||
status_icon="$ICON_DETACHED"
|
||||
status_color="$GRAY"
|
||||
else
|
||||
status_icon="$ICON_UNKNOWN"
|
||||
status_color="$YELLOW"
|
||||
fi
|
||||
|
||||
local num_str="${B}${WHITE}${num})${R}"
|
||||
local name_str="${B}${WHITE}${name}${R}"
|
||||
local status_str="${status_color}${status_icon}${R}"
|
||||
|
||||
local respawn_str=""
|
||||
if [ -n "$respawn" ]; then
|
||||
respawn_str=" ${GREEN}${respawn}${R}"
|
||||
fi
|
||||
|
||||
local token_str=""
|
||||
if [ -n "$tokens" ]; then
|
||||
token_str=" ${D}${tokens}${R}"
|
||||
fi
|
||||
|
||||
echo -e " ${num_str} ${name_str} ${status_str}${respawn_str}${token_str}"
|
||||
|
||||
if [ -n "$dir" ]; then
|
||||
dir=$(truncate "$dir" $((MAX_NAME_LEN - 2)))
|
||||
echo -e " ${D}${dir}${R}"
|
||||
fi
|
||||
}
|
||||
|
||||
print_footer() {
|
||||
local page="$1"
|
||||
local total_pages="$2"
|
||||
|
||||
echo ""
|
||||
echo -e "${D}────────────────────────────────${R}"
|
||||
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
echo -e " ${D}Page $((page+1))/$total_pages${R} ${GRAY}[${WHITE}n${GRAY}]ext [${WHITE}p${GRAY}]rev${R}"
|
||||
fi
|
||||
|
||||
echo -e " ${GRAY}[${WHITE}1-9${GRAY}]attach [${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}"
|
||||
}
|
||||
|
||||
print_no_sessions() {
|
||||
clear_screen
|
||||
echo -e "${B}${CYAN}Codeman Sessions${R}"
|
||||
echo -e "${D}$(printf '%.0s─' {1..32})${R}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}No tmux sessions found${R}"
|
||||
echo ""
|
||||
echo -e " ${D}Start one with:${R}"
|
||||
echo -e " ${WHITE}codeman web${R}"
|
||||
echo ""
|
||||
echo -e "${D}$(printf '%.0s─' {1..32})${R}"
|
||||
echo -e " ${GRAY}[${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Display Loop
|
||||
# ============================================================================
|
||||
|
||||
current_page=0
|
||||
|
||||
render() {
|
||||
clear_screen
|
||||
parse_sessions
|
||||
|
||||
local count=${#SESSION_PIDS[@]}
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
print_no_sessions
|
||||
return
|
||||
fi
|
||||
|
||||
local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE ))
|
||||
|
||||
if [ "$current_page" -ge "$total_pages" ]; then
|
||||
current_page=$((total_pages - 1))
|
||||
fi
|
||||
if [ "$current_page" -lt 0 ]; then
|
||||
current_page=0
|
||||
fi
|
||||
|
||||
local start=$((current_page * PAGE_SIZE))
|
||||
local end=$((start + PAGE_SIZE))
|
||||
if [ "$end" -gt "$count" ]; then
|
||||
end=$count
|
||||
fi
|
||||
|
||||
print_header
|
||||
echo ""
|
||||
|
||||
for ((i = start; i < end; i++)); do
|
||||
print_entry $i
|
||||
done
|
||||
|
||||
print_footer $current_page $total_pages
|
||||
}
|
||||
|
||||
attach_session() {
|
||||
local idx="$1"
|
||||
local mux_name="${MUX_NAMES[$idx]}"
|
||||
|
||||
if [ -z "$mux_name" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
clear_screen
|
||||
echo -e "${GREEN}Attaching to ${B}${DISPLAY_NAMES[$idx]}${R}${GREEN}...${R}"
|
||||
echo -e "${D}(Ctrl+B D to detach)${R}"
|
||||
sleep 0.3
|
||||
|
||||
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Input Handler
|
||||
# ============================================================================
|
||||
|
||||
handle_input() {
|
||||
local key="$1"
|
||||
local count=${#SESSION_PIDS[@]}
|
||||
local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE ))
|
||||
|
||||
case "$key" in
|
||||
[1-9])
|
||||
local idx=$((key - 1))
|
||||
if [ "$idx" -lt "$count" ]; then
|
||||
attach_session "$idx"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
|
||||
$'\e')
|
||||
read -rsn2 -t 0.1 seq 2>/dev/null || true
|
||||
case "$seq" in
|
||||
'[A'|'[D')
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page - 1 + total_pages) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
'[B'|'[C')
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page + 1) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
n|N|j|J)
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page + 1) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
|
||||
p|P|k|K)
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page - 1 + total_pages) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
|
||||
r|R)
|
||||
;;
|
||||
|
||||
q|Q)
|
||||
clear_screen
|
||||
exit 0
|
||||
;;
|
||||
|
||||
'')
|
||||
if [ "$count" -eq 1 ]; then
|
||||
attach_session 0
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# List Mode
|
||||
# ============================================================================
|
||||
|
||||
list_mode() {
|
||||
parse_sessions
|
||||
local count=${#SESSION_PIDS[@]}
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "No tmux sessions"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for ((i = 0; i < count; i++)); do
|
||||
local num=$((i + 1))
|
||||
local name="${DISPLAY_NAMES[$i]}"
|
||||
local state="${SESSION_STATES[$i]}"
|
||||
local respawn="${RESPAWN_STATUS[$i]}"
|
||||
local indicator="-"
|
||||
[[ "$state" == *"Attached"* ]] && indicator="*"
|
||||
[ -n "$respawn" ] && indicator="${indicator}R"
|
||||
|
||||
echo "$num) $name [$indicator]"
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Quick Attach
|
||||
# ============================================================================
|
||||
|
||||
quick_attach() {
|
||||
local num="$1"
|
||||
parse_sessions
|
||||
|
||||
local count=${#SESSION_PIDS[@]}
|
||||
local idx=$((num - 1))
|
||||
|
||||
if [ "$idx" -lt 0 ] || [ "$idx" -ge "$count" ]; then
|
||||
echo -e "${RED}Invalid session: $num${R}"
|
||||
echo "Available: 1-$count"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attach_session "$idx"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
show_help() {
|
||||
cat << 'EOF'
|
||||
Codeman Sessions - Mobile-friendly Tmux Session Chooser
|
||||
|
||||
USAGE:
|
||||
sc Interactive chooser
|
||||
sc <number> Quick attach to session N
|
||||
sc -l List sessions (non-interactive)
|
||||
sc -h Show this help
|
||||
|
||||
INTERACTIVE KEYS:
|
||||
1-9 Attach to session
|
||||
n/j/↓ Next page
|
||||
p/k/↑ Previous page
|
||||
r Refresh
|
||||
q Quit
|
||||
|
||||
INDICATORS:
|
||||
* / ● Attached (someone connected)
|
||||
- / ○ Detached (available)
|
||||
R Respawn enabled
|
||||
45k Token count
|
||||
|
||||
TIPS:
|
||||
- Detach from tmux: Ctrl+B D
|
||||
- Session names from Codeman state
|
||||
- Optimized for Termius/iPhone
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-l|--list)
|
||||
list_mode
|
||||
exit 0
|
||||
;;
|
||||
[1-9]|[1-9][0-9])
|
||||
quick_attach "$1"
|
||||
exit $?
|
||||
;;
|
||||
esac
|
||||
|
||||
check_deps
|
||||
|
||||
render
|
||||
|
||||
while true; do
|
||||
local timeout_opt=""
|
||||
if [ "$AUTO_REFRESH" -gt 0 ]; then
|
||||
timeout_opt="-t $AUTO_REFRESH"
|
||||
fi
|
||||
|
||||
if read -rsn1 $timeout_opt key 2>/dev/null; then
|
||||
handle_input "$key"
|
||||
fi
|
||||
render
|
||||
done
|
||||
}
|
||||
|
||||
trap 'clear_screen; exit 0' INT
|
||||
|
||||
main "$@"
|
||||
+121
-34
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.19.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.20.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -121,23 +121,53 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||
# a READY claude worker in a hook-carrying case. Anything less is rc 1 with EMPTY
|
||||
# stdout, and the half-spawned session is deleted here rather than handed back, because
|
||||
# a worker that never drew its composer would eat the task prompt with its trust
|
||||
# dialog. There is deliberately no pid poll: wait-output already blocks until the
|
||||
# composer draws, and pid!=null proved startup, never readiness.
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||
# deepseek: ask for the same permission posture the Run button sends, because the
|
||||
# harness's own default (`workspace-write`) still ASKS, and a worker that stops on
|
||||
# an approval row is a worker no fan-out can finish. It is not an escalation --
|
||||
# claude workers already spawn with permissions skipped, and in multi-user mode the
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')")
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
# Inbox all work here exactly as they do for claude. No hook file to vet
|
||||
# (the bridge is env-injected, not a workspace file) and no trust dialog.
|
||||
# ⚠️ Readiness is still not optional, and NOT interchangeable with the stop
|
||||
# signal: the harness's boot report lands ~300ms BEFORE the composer paints
|
||||
# (measured 2.26s vs 2.56s after spawn), so a sendwait fired straight after
|
||||
# quick-start returns on that BOOT signal, reports a turn that never ran, and
|
||||
# strands the prompt in a pane that was not yet taking input.
|
||||
r=$(_dsh_up "$sid" 45000)
|
||||
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"; return 0
|
||||
fi
|
||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
||||
# The server installs hooks into every claude workspace now, so this grep normally
|
||||
# passes; it stays because the install is gated on a setting the operator can turn
|
||||
# off, remote sessions never get hooks, and a session created by an older server
|
||||
@@ -166,19 +196,25 @@ spawn_worker() {
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"
|
||||
}
|
||||
# spawn_workers <caseName>... -> one "<caseName> <sessionId>" line per worker, in order;
|
||||
# the sessionId column is EMPTY for a spawn that failed (stderr has why). CONCURRENT:
|
||||
# N workers cost about what one costs. Spawning them one Bash call at a time is the
|
||||
# single biggest avoidable delay in this skill. Names must be UNIQUE: two workers in
|
||||
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race.
|
||||
# spawn_workers <caseName[:mode]>... -> one "<caseName> <sessionId>" line per worker, in
|
||||
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
|
||||
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
|
||||
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d n i=0
|
||||
local d spec n m i=0
|
||||
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||
[ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
|
||||
[ -z "$(printf '%s\n' "$@" | sed 's/:.*//' | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
|
||||
d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1
|
||||
for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done
|
||||
for spec in "$@"; do
|
||||
n=${spec%%:*}; m=${spec#*:}; [ "$m" = "$spec" ] && m=claude
|
||||
( spawn_worker "$n" "$m" > "$d/$i" ) & i=$((i+1))
|
||||
done
|
||||
wait
|
||||
i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
|
||||
i=0; for spec in "$@"; do printf '%s %s\n' "${spec%%:*}" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
|
||||
rm -rf "$d"
|
||||
}
|
||||
# sendwait <sid> <prompt> [seq] -> blocks until that worker's turn ENDS (~10 min ceiling
|
||||
@@ -194,27 +230,46 @@ spawn_workers() {
|
||||
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
||||
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
||||
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude
|
||||
# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes
|
||||
# resolve on flapping idle: markers instead (§5.5).
|
||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# implement the status contract is the one case that LOOKS like claude but is not:
|
||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||
# is still answering, and the re-wait below then resolved in 0 ms with
|
||||
# `signal:"idle"` on a turn that had another three minutes to run (measured).
|
||||
# A wait named after the end of a turn should only end with the turn, or with
|
||||
# the worker. ⚠️ This is also what makes a wrong mode LOUD: the modes that
|
||||
# cannot deliver `stop` answer 400 (before writing anything) instead of
|
||||
# resolving on a flap, which is the answer that sends you to markers (§5.5).
|
||||
body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}')
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",waitTimeout:20000}')
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$body")
|
||||
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||
# The resend is a tagged DUPLICATE, so the server skips the write and reports
|
||||
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
||||
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
||||
# as an undelivered one and keeps a finished worker forever.
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||
fi
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
# last_text <sid> [prev] -> that worker's last assistant message. Polled, because the
|
||||
# transcript write LAGS the stop signal, and "some text exists" is not "THIS turn's
|
||||
# text exists": right after a SECOND turn on the same worker the endpoint still serves
|
||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||
# deepseek write a real transcript; the other modes have none, so read the terminal
|
||||
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
|
||||
# "some text exists" is not "THIS turn's text exists": right after a SECOND turn on the same worker the endpoint still serves
|
||||
# the previous answer for a beat (observed live). When reading consecutive turns, pass
|
||||
# the previous answer as [prev]: the poll then holds out for text that differs from it,
|
||||
# falling back to whatever it last saw if the budget runs dry, so an honestly repeated
|
||||
@@ -233,10 +288,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.19.0
|
||||
CODEMAN_PREAMBLE=1.20.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -287,8 +342,9 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
'reply with one line: your model name') # tasks, same order as N
|
||||
|
||||
@@ -353,6 +409,37 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- Deleting the sessions does **not** remove the case directories: §5.14.
|
||||
|
||||
### DeepSeek Harness workers
|
||||
|
||||
The block above spawns claude workers. Any entry in `N` may instead name a mode
|
||||
(`beta:deepseek`), and **a `deepseek` worker is driven by the same four verbs, with no
|
||||
change to the rest of the block**: `spawn_workers` waits for its composer, `sendwait`
|
||||
blocks on its real end-of-turn signal, `last_text` reads its answer, `delete_session`
|
||||
removes it.
|
||||
|
||||
That is true of no other non-claude mode, and it is worth knowing why: the DeepSeek
|
||||
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
|
||||
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
||||
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
||||
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
|
||||
`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
||||
|
||||
Three things to know before you spawn one:
|
||||
|
||||
- **It needs a pane-capable profile.** `dsh` ships only `web`/`headless`, so the terminal
|
||||
agent is always an installed profile. `GET /api/v1/deepseek/status` answers both
|
||||
questions separately (`available` = the binary, `runnable` = a profile that can drive a
|
||||
pane); a spawn without one fails with `OPERATION_FAILED` rather than falling back.
|
||||
- **Do not task it on the strength of a `stop` alone.** The harness reports `idle` at
|
||||
boot ~300 ms *before* its composer paints (measured 2.26 s vs 2.56 s), so a `sendwait`
|
||||
fired straight after `quick-start` resolves on that boot signal, reports a turn that
|
||||
never ran, and leaves the prompt in a pane that was not yet taking input. Letting
|
||||
`spawn_worker` gate on readiness is what steps past that edge; it is not optional.
|
||||
- **A profile that does not implement the contract looks like a hang.** Codeman cannot
|
||||
know at spawn time whether one does. The tell is a `sendwait` that times out on a
|
||||
worker whose pane clearly finished: that profile is one of them, so drive it with
|
||||
markers instead.
|
||||
|
||||
## 2. What do you want to do?
|
||||
|
||||
One row per job. Acting on this table alone is correct; the §5 links are the detail.
|
||||
@@ -360,10 +447,10 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| I want to | Call | Detail |
|
||||
|-----------|------|--------|
|
||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
|
||||
@@ -464,7 +551,7 @@ these**; open the one row you actually hit.
|
||||
| [5.1 Where to spawn](reference/verbs.md#51-where-to-spawn) | the work is **not** a fresh scratch case: a linked case, a git worktree, any path that already existed. Hooks are absent there, which silently breaks send-and-wait. The costliest mistake in this skill |
|
||||
| [5.2 Readiness](reference/verbs.md#52-readiness) | a worker never drew its composer, or you need the trust-dialog ladder by hand |
|
||||
| [5.3 Send a task and wait](reference/verbs.md#53-send-a-task-and-wait) | the `sendwait` body, its signals, and the duplicate-resend loop |
|
||||
| [5.4 Read the answer](reference/verbs.md#54-read-the-answer) | `last_text` came back empty, or the mode is not claude/codex |
|
||||
| [5.4 Read the answer](reference/verbs.md#54-read-the-answer) | `last_text` came back empty, or the mode is not claude/codex/deepseek |
|
||||
| [5.5 Markers for hook-less workers](reference/verbs.md#55-markers-for-hook-less-workers) | the worker has no `stop` hook: synchronize on a split, unique printed marker |
|
||||
| [5.6 Alive and stuck](reference/verbs.md#56-alive-and-stuck) | is it dead or just slow? `status` and `pid` both lie |
|
||||
| [5.7 Interrupt without destroying](reference/verbs.md#57-interrupt-without-destroying) | a runaway worker you want to stop but keep |
|
||||
|
||||
+81
-26
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -43,23 +43,53 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||
# a READY claude worker in a hook-carrying case. Anything less is rc 1 with EMPTY
|
||||
# stdout, and the half-spawned session is deleted here rather than handed back, because
|
||||
# a worker that never drew its composer would eat the task prompt with its trust
|
||||
# dialog. There is deliberately no pid poll: wait-output already blocks until the
|
||||
# composer draws, and pid!=null proved startup, never readiness.
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||
# deepseek: ask for the same permission posture the Run button sends, because the
|
||||
# harness's own default (`workspace-write`) still ASKS, and a worker that stops on
|
||||
# an approval row is a worker no fan-out can finish. It is not an escalation --
|
||||
# claude workers already spawn with permissions skipped, and in multi-user mode the
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')")
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
# Inbox all work here exactly as they do for claude. No hook file to vet
|
||||
# (the bridge is env-injected, not a workspace file) and no trust dialog.
|
||||
# ⚠️ Readiness is still not optional, and NOT interchangeable with the stop
|
||||
# signal: the harness's boot report lands ~300ms BEFORE the composer paints
|
||||
# (measured 2.26s vs 2.56s after spawn), so a sendwait fired straight after
|
||||
# quick-start returns on that BOOT signal, reports a turn that never ran, and
|
||||
# strands the prompt in a pane that was not yet taking input.
|
||||
r=$(_dsh_up "$sid" 45000)
|
||||
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"; return 0
|
||||
fi
|
||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
||||
# The server installs hooks into every claude workspace now, so this grep normally
|
||||
# passes; it stays because the install is gated on a setting the operator can turn
|
||||
# off, remote sessions never get hooks, and a session created by an older server
|
||||
@@ -88,19 +118,25 @@ spawn_worker() {
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"
|
||||
}
|
||||
# spawn_workers <caseName>... -> one "<caseName> <sessionId>" line per worker, in order;
|
||||
# the sessionId column is EMPTY for a spawn that failed (stderr has why). CONCURRENT:
|
||||
# N workers cost about what one costs. Spawning them one Bash call at a time is the
|
||||
# single biggest avoidable delay in this skill. Names must be UNIQUE: two workers in
|
||||
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race.
|
||||
# spawn_workers <caseName[:mode]>... -> one "<caseName> <sessionId>" line per worker, in
|
||||
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
|
||||
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
|
||||
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d n i=0
|
||||
local d spec n m i=0
|
||||
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||
[ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
|
||||
[ -z "$(printf '%s\n' "$@" | sed 's/:.*//' | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
|
||||
d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1
|
||||
for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done
|
||||
for spec in "$@"; do
|
||||
n=${spec%%:*}; m=${spec#*:}; [ "$m" = "$spec" ] && m=claude
|
||||
( spawn_worker "$n" "$m" > "$d/$i" ) & i=$((i+1))
|
||||
done
|
||||
wait
|
||||
i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
|
||||
i=0; for spec in "$@"; do printf '%s %s\n' "${spec%%:*}" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
|
||||
rm -rf "$d"
|
||||
}
|
||||
# sendwait <sid> <prompt> [seq] -> blocks until that worker's turn ENDS (~10 min ceiling
|
||||
@@ -116,27 +152,46 @@ spawn_workers() {
|
||||
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
||||
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
||||
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude
|
||||
# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes
|
||||
# resolve on flapping idle: markers instead (§5.5).
|
||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# implement the status contract is the one case that LOOKS like claude but is not:
|
||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||
# is still answering, and the re-wait below then resolved in 0 ms with
|
||||
# `signal:"idle"` on a turn that had another three minutes to run (measured).
|
||||
# A wait named after the end of a turn should only end with the turn, or with
|
||||
# the worker. ⚠️ This is also what makes a wrong mode LOUD: the modes that
|
||||
# cannot deliver `stop` answer 400 (before writing anything) instead of
|
||||
# resolving on a flap, which is the answer that sends you to markers (§5.5).
|
||||
body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}')
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",waitTimeout:20000}')
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$body")
|
||||
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||
# The resend is a tagged DUPLICATE, so the server skips the write and reports
|
||||
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
||||
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
||||
# as an undelivered one and keeps a finished worker forever.
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||
fi
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
# last_text <sid> [prev] -> that worker's last assistant message. Polled, because the
|
||||
# transcript write LAGS the stop signal, and "some text exists" is not "THIS turn's
|
||||
# text exists": right after a SECOND turn on the same worker the endpoint still serves
|
||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||
# deepseek write a real transcript; the other modes have none, so read the terminal
|
||||
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
|
||||
# "some text exists" is not "THIS turn's text exists": right after a SECOND turn on the same worker the endpoint still serves
|
||||
# the previous answer for a beat (observed live). When reading consecutive turns, pass
|
||||
# the previous answer as [prev]: the poll then holds out for text that differs from it,
|
||||
# falling back to whatever it last saw if the budget runs dry, so an honestly repeated
|
||||
@@ -155,4 +210,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.19.0
|
||||
CODEMAN_PREAMBLE=1.20.0
|
||||
|
||||
@@ -237,7 +237,10 @@ minutes, never retry the credential.
|
||||
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||
`shell`, `opencode`, `gemini`, `antigravity` and `pi`, which write no Claude transcript.
|
||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
|
||||
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
||||
for the same reason claude does (the harness finalizes the assistant message just after
|
||||
it reports `idle`), so poll it the same way.
|
||||
|
||||
**Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode,
|
||||
that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
|
||||
@@ -279,7 +282,7 @@ than into an existing checkout.
|
||||
| start case + session in one call | `POST /api/v1/quick-start` |
|
||||
| create a session in an arbitrary directory (no case, **no PTY**, id at `.data.session.id`) | `POST /api/v1/sessions`, then `POST /api/v1/sessions/:id/interactive` or `.../shell` to start it, see [Starting a worker](#starting-a-worker) |
|
||||
| send input | `POST /api/v1/sessions/:id/input` |
|
||||
| **read a worker's answer** (claude/codex) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
|
||||
| **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
@@ -321,7 +324,7 @@ on signals and markers for exactly this reason.
|
||||
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
|
||||
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
|
||||
JSON-stream path). Verified empty on live claude and shell sessions. Use
|
||||
`last-response` for claude/codex answers; only fall back to `terminal?tail=` for
|
||||
`last-response` for claude/codex/deepseek answers; only fall back to `terminal?tail=` for
|
||||
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
||||
|
||||
```bash
|
||||
@@ -336,19 +339,20 @@ ESC=$(printf '\033')
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi`; response is
|
||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||
|
||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`
|
||||
and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed).
|
||||
Pi's also carries `.data.version`, because `pi` is a short generic name that an unrelated
|
||||
binary on `$PATH` can shadow: the resolver rejects one whose `--version` is not
|
||||
semver-shaped, so `available:false` there can mean "a different `pi` is in front" rather
|
||||
than "nothing is installed". `shell` has no CLI to probe.
|
||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
||||
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
|
||||
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
|
||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
|
||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||
|
||||
⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field
|
||||
is absent, `jq -r` prints the literal string `null`, and every later call then targets
|
||||
@@ -462,10 +466,10 @@ Quirks that will bite you:
|
||||
session answers with an empty timeline rather than a 404.
|
||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||
returns early for every external CLI mode (`session.ts:2136`), so it is permanently
|
||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`. ⚠️ **`shell` is NOT one of those**
|
||||
(`isExternalCliMode`, `session.ts:165-167`, lists only those five), so the parser does
|
||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:88`) matches
|
||||
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
|
||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
||||
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
||||
a shell worker running `cat build.log` really does populate this. In practice it stays
|
||||
empty for most shell work. It also never sees non-Bash
|
||||
@@ -639,10 +643,14 @@ block, so a linked case or a raw `workingDir` had no hooks at all. `POST
|
||||
session-create path installs hooks regardless of how the directory got there. See
|
||||
[symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns).
|
||||
|
||||
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
|
||||
`stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
|
||||
Default `until` set: `stop,idle,exit`. On modes with no hook signals the server silently
|
||||
drops `stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
|
||||
`["idle","exit"]` on shell); requesting them *explicitly* there is a 400 naming the
|
||||
mode. ⚠️ That 400 is about **mode**, so a hooks-less *claude* session accepts
|
||||
mode. ⚠️ `deepseek` is not one of those: its harness reports its own lifecycle, so it
|
||||
keeps the full default set and accepts an explicit `until=stop`. ⚠️ For dsh the answer is
|
||||
per-SESSION rather than per-mode — a session created with `statusReporting: false` has no
|
||||
bridge, and an explicit `until=stop` there is a 400 naming that setting. ⚠️ That 400 is
|
||||
otherwise about **mode**, so a hooks-less *claude* session accepts
|
||||
`until=stop` happily and then never resolves it. ⚠️ On hook-less modes the lifecycle
|
||||
signals are also **coarse in practice**: a
|
||||
short shell command produced **no** `idle` transition within 60 s (verified live), so
|
||||
@@ -790,7 +798,8 @@ for environment and setup problems.
|
||||
| `CODEMAN_MUX` unset but you seem to be in a session | remote-SSH case: the env vars are not exported there. Fail closed, refuse to act |
|
||||
| connection refused from inside a container | a loopback-bound server is unreachable from a container, and `CODEMAN_DOCKER_BRIDGE_HOOKS=1` does **not** fix that: it opens a hooks-only listener, so hook events start flowing but `/api/v1/*` stays refused. Driving the API from inside a Docker case needs a reachable bind (an operator decision); report it, don't retry |
|
||||
| wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare), poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server |
|
||||
| wait on `stop` never resolves | non-claude mode, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
|
||||
| wait on `stop` never resolves | a mode with no hook signals, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
|
||||
| wait on `stop` never resolves, on a **dsh** worker whose pane clearly finished | that profile does not implement the harness's supervisor contract, which Codeman cannot detect at request time (an unrecognized profile is treated as launchable on purpose). The wait is accepted and then times out. Drive that worker with markers, or switch to a profile that reports — `@deepseek-harness-tui/dsh-tui` does |
|
||||
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and an attempt cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, accept the dialog only as the bounded fallback |
|
||||
| readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the effective per-session value is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). Expect `blocked` signals mid-turn on the non-default modes |
|
||||
| ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above |
|
||||
|
||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||
| liveness / death check | HTTP `wait?until=exit` |
|
||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`) | HTTP only (no other CLI has messaging) |
|
||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
|
||||
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
||||
|
||||
## Availability: probe, never assume
|
||||
@@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
|
||||
|
||||
### Mixed fleets: the pairing matrix
|
||||
|
||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`) cannot be peers
|
||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
|
||||
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
||||
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
||||
subject to the namespace rule: **messaging works between two sessions that share one
|
||||
|
||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity/pi, which have no transcript, use
|
||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
|
||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||
|
||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||
@@ -198,6 +198,59 @@ delete_session "$SID"
|
||||
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
|
||||
re-ask about the same delivery (the duplicate-wait loop above).
|
||||
|
||||
## Flow 1b: DeepSeek Harness worker, end to end
|
||||
|
||||
A `deepseek` worker is driven with the same four verbs as a claude one, because the
|
||||
harness reports its own lifecycle: its `stop` is a real end-of-turn signal, and its
|
||||
answer comes from a real transcript. The differences are all at the edges.
|
||||
|
||||
```bash
|
||||
# 0. Is there anything to spawn? `available` is the binary, `runnable` is a profile
|
||||
# that can drive a pane -- dsh ships only web/headless, so the two differ.
|
||||
"${CURL[@]}" "$API/api/v1/deepseek/status" | jq -c '{available:.data.available,runnable:.data.runnable,profile:.data.defaultProfile}'
|
||||
|
||||
# 1. Spawn. `deepSeekConfig` is optional: an absent profile picks the first
|
||||
# pane-capable one, and an absent permissionMode leaves the harness on its own
|
||||
# workspace-write default, which still ASKS before it acts.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"dsh-worker","mode":"deepseek","deepSeekConfig":{"permissionMode":"danger-full-access"}}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; exit 1; } # OPERATION_FAILED = no runnable profile
|
||||
CREATED+=("$SID")
|
||||
|
||||
# 2. Readiness, and ONLY readiness. ⚠️ Do not use the stop signal for this: the
|
||||
# harness reports idle at BOOT, ~300 ms before the composer paints.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=❯' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000' \
|
||||
| jq -e '.data.wait.matched' >/dev/null || { echo "no composer"; delete_session "$SID"; exit 1; }
|
||||
|
||||
# 3. Task it. Identical to a claude worker, including the \r and the (clientId, seq).
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"Read calc.py and tell me in one sentence whether add() is correct.\r","useMux":true,"clientId":"codeman-dsh-1","seq":1,"wait":"stop,exit","waitTimeout":300000}' \
|
||||
| jq -c '{delivered:.data.delivered,signal:.data.wait.signal,timedOut:.data.wait.timedOut}'
|
||||
|
||||
# 4. Read it. From $DSH_HOME/sessions/**, not the pane -- scraping a dsh pane returns
|
||||
# its ASCII-art splash. Poll: the harness finalizes the message just after it
|
||||
# reports idle. Two answers are not the model's words and say so:
|
||||
# "Turn error: …" (the provider or harness failed) and "Turn ended: …" (early stop).
|
||||
for _ in $(seq 1 15); do
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
|
||||
# 5. Full conversation, if you need the tool calls too:
|
||||
# "${CURL[@]}" "$API/api/v1/sessions/$SID/last-response?context=full" | jq -r '.data.messages[]|"[\(.label)] \(.text)"'
|
||||
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
⚠️ **`wait:"stop,exit"`, not `wait:true`.** The default set also carries `idle`, which
|
||||
for an external CLI is inferred from output stabilization: a dsh TUI that repaints
|
||||
rarely reads as idle mid-turn, and a wait carrying `idle` then resolves in 0 ms on a
|
||||
turn with minutes left to run (measured). The same reason the preamble's `sendwait`
|
||||
asks for `stop,exit` on every mode.
|
||||
|
||||
## Flow 2: shell worker, marker-synchronized
|
||||
|
||||
`shell` sessions have no hooks (`stop`/`blocked` are a 400 there), and their lifecycle
|
||||
|
||||
@@ -152,7 +152,19 @@ It is **decoration, and resolved rather than trusted**, so treat it accordingly:
|
||||
|
||||
### 5.2 Readiness
|
||||
|
||||
A new session reports `idle` before its CLI has spawned, and a brand-new case shows a
|
||||
**dsh workers first**, because their trap is the opposite of claude's: they have no
|
||||
trust dialog and boot straight into a composer (`❯`, matched `from=buffer`), but the
|
||||
harness reports `idle` — which reaches you as a `stop` signal — about 300 ms BEFORE that
|
||||
composer paints (measured 2.26 s vs 2.56 s after spawn, twice). So the signal that means
|
||||
"this worker finished its turn" is also the first thing it emits at boot, and a
|
||||
send-and-wait fired straight after `quick-start` resolves on it, reports a turn that
|
||||
never ran, and leaves the prompt in a pane that was not yet taking input. Wait for the
|
||||
composer, not for the signal; `spawn_worker` does exactly that, and by the time it
|
||||
returns the boot edge is spent (signals are edge-triggered, so nothing can catch it
|
||||
later). A profile whose composer is not `❯` needs `DSH_READY_MARK` set to whatever it
|
||||
does draw.
|
||||
|
||||
For claude: a new session reports `idle` before its CLI has spawned, and a brand-new case shows a
|
||||
**trust dialog** first, so neither "wait for idle" nor "wait for ❯" means ready (the
|
||||
trust dialog contains `❯` too, observed live). Codeman auto-accepts that dialog
|
||||
itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialog()`
|
||||
@@ -341,17 +353,35 @@ If the loop exhausts its cap, do not keep looping: read the terminal, report wha
|
||||
see, and remember that a still-typed-but-unsubmitted prompt (missing `\r`) can only be
|
||||
recovered by submitting it with `{"input":"\r"}`.
|
||||
|
||||
⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks,
|
||||
and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`, requesting them explicitly is a
|
||||
⚠️ `stop` and `blocked` fire for `claude` sessions (they are Claude Code hooks, and
|
||||
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||
real end-of-turn signal rather than a guess. On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
|
||||
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||
`idle` transition at all, verified live), so synchronize those with markers.
|
||||
|
||||
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
|
||||
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
|
||||
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
|
||||
whether the installed profile implements the supervisor contract cannot be known at
|
||||
request time, so a non-conforming one accepts the wait and times out on it. One timeout
|
||||
on a dsh worker whose pane clearly finished identifies that profile; switch it to
|
||||
markers.
|
||||
|
||||
### 5.4 Read the answer
|
||||
|
||||
For `claude` and `codex` workers this is the read path: `last-response` returns the
|
||||
agent's final message as clean text, taken from the transcript rather than the screen,
|
||||
so it carries none of the TUI's box-drawing or repaint noise.
|
||||
For `claude`, `codex` and `deepseek` workers this is the read path: `last-response`
|
||||
returns the agent's final message as clean text, taken from the transcript rather than
|
||||
the screen, so it carries none of the TUI's box-drawing or repaint noise.
|
||||
|
||||
⚠️ For `deepseek` it reads `$DSH_HOME/sessions/**`, and reading it is the ONLY way to
|
||||
get that answer: dsh-TUI paints a full-screen splash, so scraping its pane returns the
|
||||
ASCII-art logo (that is what `last-response` itself used to return for dsh). Two dsh
|
||||
answers are not the model's words and say so: `Turn error: …` (the provider or the
|
||||
harness failed the turn) and `Turn ended: …` (an early stop such as `max-tokens`). A
|
||||
turn still streaming reads back as the partial answer so far, so a non-empty read is
|
||||
not by itself proof the turn ended — that is what the `stop` signal is for.
|
||||
|
||||
```bash
|
||||
for _ in $(seq 1 10); do # the transcript write LAGS the stop signal
|
||||
@@ -369,9 +399,10 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`; the first four
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
|
||||
verified live, pi from the same source path), which is
|
||||
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer
|
||||
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
||||
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
||||
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
|
||||
sessions; don't use it):
|
||||
|
||||
@@ -454,7 +485,7 @@ turn), and both better than diffing terminal samples:
|
||||
```
|
||||
|
||||
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`** (those parsers are skipped wholesale) and
|
||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
|
||||
in practice empty for `shell`. Source-verified, not measured live.
|
||||
|
||||
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @fileoverview One style vocabulary for everything the `codeman` CLI prints:
|
||||
* palette, glyphs, the small block helpers (heading/rule/kv), width-aware table
|
||||
* layout, a stderr spinner and a y/N confirm.
|
||||
*
|
||||
* Color detection is chalk's alone. It already honors NO_COLOR, FORCE_COLOR,
|
||||
* TERM=dumb and TTY-ness, and a second detector here would disagree with it on
|
||||
* some terminal with no way to tell which one was right.
|
||||
*
|
||||
* The layout math is pure and exported separately from anything that touches a
|
||||
* terminal, which is what lets it be unit-tested with no TTY and reused by
|
||||
* `utils/dependency-report.ts` while that file stays color-free.
|
||||
*
|
||||
* @module cli-style
|
||||
*/
|
||||
|
||||
import chalk, { type ChalkInstance } from 'chalk';
|
||||
import { createInterface } from 'node:readline';
|
||||
// Direct import, not the `utils` barrel: the barrel pulls in node-pty and every
|
||||
// CLI resolver, which a style module has no business loading.
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Palette and glyphs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Semantic roles, mirroring the web UI's status language (green fine, yellow waiting, red blocked). */
|
||||
export const palette = {
|
||||
ok: chalk.green,
|
||||
warn: chalk.yellow,
|
||||
err: chalk.red,
|
||||
info: chalk.cyan,
|
||||
muted: chalk.gray,
|
||||
emph: chalk.bold,
|
||||
accent: chalk.magenta,
|
||||
} as const satisfies Record<string, ChalkInstance>;
|
||||
|
||||
/** The glyph vocabulary the CLI already used, in one place. */
|
||||
export const GLYPH = {
|
||||
ok: '✓',
|
||||
fail: '✗',
|
||||
warn: '⚠',
|
||||
idle: '○',
|
||||
dot: '●',
|
||||
arrow: '→',
|
||||
} as const;
|
||||
|
||||
/** Spinner frames (braille, one cell wide in every terminal we support). */
|
||||
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
/** What a line is reporting, independent of how it is painted. */
|
||||
export type Tone = 'ok' | 'warn' | 'err' | 'idle' | 'info';
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = {
|
||||
ok: GLYPH.ok,
|
||||
warn: GLYPH.warn,
|
||||
err: GLYPH.fail,
|
||||
idle: GLYPH.idle,
|
||||
info: GLYPH.dot,
|
||||
};
|
||||
|
||||
const TONE_STYLE: Record<Tone, ChalkInstance> = {
|
||||
ok: palette.ok,
|
||||
warn: palette.warn,
|
||||
err: palette.err,
|
||||
idle: palette.muted,
|
||||
info: palette.info,
|
||||
};
|
||||
|
||||
/** Glyph for a tone. Pure, so the mapping is testable without a terminal. */
|
||||
export function glyphFor(tone: Tone): string {
|
||||
return TONE_GLYPH[tone];
|
||||
}
|
||||
|
||||
/** Paint text in a tone's color. */
|
||||
export function tint(tone: Tone, text: string): string {
|
||||
return TONE_STYLE[tone](text);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Blocks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Section heading. The blank line above it is part of the existing block idiom. */
|
||||
export function heading(text: string): string {
|
||||
return `\n${palette.emph(text)}`;
|
||||
}
|
||||
|
||||
/** Horizontal rule under a title. */
|
||||
export function rule(width = 40): string {
|
||||
return palette.muted('─'.repeat(Math.max(0, width)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indented `Label: value` line. `pad` aligns the values of a block by padding
|
||||
* the label column (including its colon), for blocks whose labels differ in
|
||||
* length.
|
||||
*/
|
||||
export function kv(label: string, value: string, pad = 0): string {
|
||||
const key = pad > 0 ? padCell(`${label}:`, pad) : `${label}:`;
|
||||
return ` ${key} ${value}`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Width-aware layout (pure)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Printed width of a cell: ANSI sequences take no columns. */
|
||||
export function displayWidth(text: string): number {
|
||||
return stripAnsi(text).length;
|
||||
}
|
||||
|
||||
export type CellAlign = 'left' | 'right';
|
||||
|
||||
/** Pad to `width` columns, measuring by display width so colored cells still align. */
|
||||
export function padCell(text: string, width: number, align: CellAlign = 'left'): string {
|
||||
const fill = ' '.repeat(Math.max(0, width - displayWidth(text)));
|
||||
return align === 'right' ? `${fill}${text}` : `${text}${fill}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad AFTER the paint, so the fill stays outside the color run and a trailing
|
||||
* empty column can be trimmed away instead of ending in a reset sequence with
|
||||
* invisible spaces before it.
|
||||
*/
|
||||
export function padStyled(text: string, width: number, paint: (t: string) => string): string {
|
||||
return `${paint(text)}${' '.repeat(Math.max(0, width - displayWidth(text)))}`;
|
||||
}
|
||||
|
||||
/** Widest cell per column. Short rows count as empty cells, never as narrower columns. */
|
||||
export function columnWidths(rows: readonly (readonly string[])[]): number[] {
|
||||
const widths: number[] = [];
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
widths[i] = Math.max(widths[i] ?? 0, displayWidth(row[i] ?? ''));
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
export interface TableOptions {
|
||||
/** Per-column alignment; missing entries are left-aligned. */
|
||||
align?: readonly CellAlign[];
|
||||
/** Spaces between columns. */
|
||||
gap?: number;
|
||||
/** Prefix for every row. */
|
||||
indent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay rows out in columns sized to their widest cell. The last cell of a row is
|
||||
* never padded, so no line carries trailing whitespace.
|
||||
*/
|
||||
export function layoutTable(rows: readonly (readonly string[])[], options: TableOptions = {}): string[] {
|
||||
const { align = [], gap = 1, indent = '' } = options;
|
||||
const widths = columnWidths(rows);
|
||||
const separator = ' '.repeat(Math.max(0, gap));
|
||||
return rows.map((row) => {
|
||||
const cells = row.map((cell, i) => (i === row.length - 1 ? cell : padCell(cell, widths[i], align[i] ?? 'left')));
|
||||
return `${indent}${cells.join(separator)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** `layoutTable()` as one printable block. */
|
||||
export function table(rows: readonly (readonly string[])[], options: TableOptions = {}): string {
|
||||
return layoutTable(rows, options).join('\n');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Spinner
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
const CLEAR_LINE = '\x1b[K';
|
||||
|
||||
/** The slice of a stream a spinner needs; `process.stderr` satisfies it. */
|
||||
export interface SpinnerStream {
|
||||
isTTY?: boolean;
|
||||
write(chunk: string): unknown;
|
||||
}
|
||||
|
||||
export interface Spinner {
|
||||
start(): Spinner;
|
||||
/** Change the text mid-flight. Silent on a non-TTY, which prints once and stops. */
|
||||
setText(text: string): void;
|
||||
/** Clear the line, restore the cursor and optionally print a final line. */
|
||||
stop(finalLine?: string): void;
|
||||
}
|
||||
|
||||
export interface SpinnerOptions {
|
||||
stream?: SpinnerStream;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place progress line on stderr, for the calls that block for tens of seconds
|
||||
* (daemon start, service install). Only a TTY gets the animation: piped output
|
||||
* and journald get the text once, so a log file never fills with `\r` frames.
|
||||
*/
|
||||
export function spinner(text: string, options: SpinnerOptions = {}): Spinner {
|
||||
const stream = options.stream ?? process.stderr;
|
||||
const intervalMs = options.intervalMs ?? 90;
|
||||
const animated = Boolean(stream.isTTY);
|
||||
let label = text;
|
||||
let frame = 0;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
|
||||
const restoreCursor = () => {
|
||||
if (animated) stream.write(SHOW_CURSOR);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
stream.write(`\r${palette.info(SPINNER_FRAMES[frame % SPINNER_FRAMES.length])} ${label}${CLEAR_LINE}`);
|
||||
frame++;
|
||||
};
|
||||
|
||||
const handle: Spinner = {
|
||||
start() {
|
||||
if (started || stopped) return handle;
|
||||
started = true;
|
||||
if (!animated) {
|
||||
stream.write(`${label}\n`);
|
||||
return handle;
|
||||
}
|
||||
stream.write(HIDE_CURSOR);
|
||||
// A hidden cursor left behind by a Ctrl+C outlives the process, so the
|
||||
// exit hook is not optional.
|
||||
process.once('exit', restoreCursor);
|
||||
render();
|
||||
// Unref'd: a spinner must never be the reason the process stays alive.
|
||||
timer = setInterval(render, intervalMs);
|
||||
timer.unref();
|
||||
return handle;
|
||||
},
|
||||
setText(next: string) {
|
||||
label = next;
|
||||
if (animated && started && !stopped) render();
|
||||
},
|
||||
stop(finalLine?: string) {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (animated && started) {
|
||||
stream.write(`\r${CLEAR_LINE}`);
|
||||
restoreCursor();
|
||||
process.off('exit', restoreCursor);
|
||||
}
|
||||
if (finalLine && animated) stream.write(`${finalLine}\n`);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Run `work` with a spinner up, stopping it however `work` ends. */
|
||||
export async function withSpinner<T>(text: string, work: () => Promise<T>, options?: SpinnerOptions): Promise<T> {
|
||||
const handle = spinner(text, options).start();
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
handle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Confirm
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Is there a human on the other end of both halves of the terminal? */
|
||||
export function isInteractive(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* y/N prompt. Answers `false` immediately when stdin is not a TTY (a script
|
||||
* piping into the CLI must never hang on an invisible question), so callers
|
||||
* that support a `--force` flag can branch on `isInteractive()` to keep printing
|
||||
* their "pass --force" hint instead.
|
||||
*/
|
||||
export async function confirm(question: string): Promise<boolean> {
|
||||
if (!isInteractive()) return false;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.once('SIGINT', () => resolve(''));
|
||||
rl.question(`${question} ${palette.muted('[y/N]')} `, resolve);
|
||||
});
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
} finally {
|
||||
rl.close();
|
||||
// readline resumes stdin; a still-flowing stdin keeps the process alive.
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
+270
-205
@@ -8,7 +8,6 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { createRequire } from 'module';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
@@ -26,6 +25,9 @@ import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||
import { confirm, heading, isInteractive, kv, palette, rule, tint, withSpinner, type Tone } from './cli-style.js';
|
||||
import type { ToolResult } from './utils/dependency-checker.js';
|
||||
import type { ReportStyle } from './utils/dependency-report.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { version: string };
|
||||
@@ -107,14 +109,14 @@ program
|
||||
.action(async (filePath, options) => {
|
||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
|
||||
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
|
||||
console.log(chalk.green('✓ Attachment card requested'));
|
||||
console.log(palette.ok('✓ Attachment card requested'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,7 +176,7 @@ export function resolveSkillTargetPath(options: {
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
const resolved = resolveSkillTargetPath(options);
|
||||
if (resolved.missingCase !== undefined) {
|
||||
console.error(chalk.red(`✗ Case not found: ${resolved.missingCase}`));
|
||||
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return resolved.target;
|
||||
@@ -199,9 +201,9 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void
|
||||
};
|
||||
const message = messages[result];
|
||||
if (message.ok) {
|
||||
console.log(chalk.green(`✓ ${message.text}`));
|
||||
console.log(palette.ok(`✓ ${message.text}`));
|
||||
} else {
|
||||
console.error(chalk.red(`✗ ${message.text}`));
|
||||
console.error(palette.err(`✗ ${message.text}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -220,7 +222,7 @@ skillCmd
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -235,7 +237,7 @@ skillCmd
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -252,11 +254,11 @@ sessionCmd
|
||||
try {
|
||||
const manager = getSessionManager();
|
||||
const session = await manager.createSession(options.dir);
|
||||
console.log(chalk.green(`✓ Session started: ${session.id}`));
|
||||
console.log(palette.ok(`✓ Session started: ${session.id}`));
|
||||
console.log(` Working directory: ${session.workingDir}`);
|
||||
console.log(` PID: ${session.pid}`);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start session: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to start session: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -268,70 +270,81 @@ sessionCmd
|
||||
try {
|
||||
const manager = getSessionManager();
|
||||
await manager.stopSession(id);
|
||||
console.log(chalk.green(`✓ Session stopped: ${id}`));
|
||||
console.log(palette.ok(`✓ Session stopped: ${id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to stop session: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to stop session: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
/** Session status in the shared vocabulary: idle is fine, busy is working, anything else is a problem. */
|
||||
function sessionStatusLabel(status: string): string {
|
||||
if (status === 'idle') return palette.ok('idle');
|
||||
if (status === 'busy') return palette.warn('busy');
|
||||
return palette.err(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one session listing. `codeman list` used to be a copy of this that had
|
||||
* drifted (it lost the stopped and web-server sections), so it now calls the
|
||||
* same renderer and only opts out of those two sections.
|
||||
*/
|
||||
function printSessionList(options: { includeStored: boolean }): void {
|
||||
const manager = getSessionManager();
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
|
||||
if (sessions.length === 0 && Object.keys(stored).length === 0) {
|
||||
console.log(palette.warn('No sessions found'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(heading('Active Sessions:'));
|
||||
if (sessions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
console.log(
|
||||
` ${palette.info(session.id.slice(0, 8))} ${sessionStatusLabel(session.status)} ${session.workingDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeStored) {
|
||||
const stoppedSessions = Object.values(stored).filter((s) => s.status === 'stopped');
|
||||
if (stoppedSessions.length > 0) {
|
||||
console.log(heading('Stopped Sessions:'));
|
||||
for (const session of stoppedSessions) {
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
console.log(
|
||||
` ${palette.muted(session.id.slice(0, 8))} ${palette.muted('stopped')}${name} ${session.workingDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions the web server owns: this process has no PTY for them, so they
|
||||
// only exist in the shared state file.
|
||||
const activeSessions = Object.values(stored).filter((s) => s.status !== 'stopped');
|
||||
if (sessions.length === 0 && activeSessions.length > 0) {
|
||||
console.log(heading('Active Sessions (from web server):'));
|
||||
for (const session of activeSessions) {
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
const mode = session.mode === 'shell' ? palette.muted(' [shell]') : '';
|
||||
const cost = session.totalCost ? palette.muted(` $${session.totalCost.toFixed(4)}`) : '';
|
||||
console.log(
|
||||
` ${palette.info(session.id.slice(0, 8))} ${sessionStatusLabel(session.status)}${name}${mode}${cost} ${session.workingDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
sessionCmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all sessions')
|
||||
.action(() => {
|
||||
const manager = getSessionManager();
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
|
||||
if (sessions.length === 0 && Object.keys(stored).length === 0) {
|
||||
console.log(chalk.yellow('No sessions found'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nActive Sessions:'));
|
||||
if (sessions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedSessions = Object.values(stored).filter((s) => s.status === 'stopped');
|
||||
if (stoppedSessions.length > 0) {
|
||||
console.log(chalk.bold('\nStopped Sessions:'));
|
||||
for (const session of stoppedSessions) {
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show active sessions from state (when web server manages them)
|
||||
const activeSessions = Object.values(stored).filter((s) => s.status !== 'stopped');
|
||||
if (sessions.length === 0 && activeSessions.length > 0) {
|
||||
console.log(chalk.bold('\nActive Sessions (from web server):'));
|
||||
for (const session of activeSessions) {
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
|
||||
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
.action(() => printSessionList({ includeStored: true }));
|
||||
|
||||
sessionCmd
|
||||
.command('logs <id>')
|
||||
@@ -342,12 +355,12 @@ sessionCmd
|
||||
const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id);
|
||||
|
||||
if (output === null) {
|
||||
console.log(chalk.yellow(`Session ${id} not found or not active`));
|
||||
console.log(palette.warn(`Session ${id} not found or not active`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (output === '') {
|
||||
console.log(chalk.gray('(no output)'));
|
||||
console.log(palette.muted('(no output)'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -374,7 +387,7 @@ taskCmd
|
||||
completionPhrase: options.completion,
|
||||
timeoutMs: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
});
|
||||
console.log(chalk.green(`✓ Task added: ${task.id}`));
|
||||
console.log(palette.ok(`✓ Task added: ${task.id}`));
|
||||
console.log(` Prompt: ${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}`);
|
||||
console.log(` Priority: ${task.priority}`);
|
||||
});
|
||||
@@ -393,26 +406,28 @@ taskCmd
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log(chalk.yellow('No tasks found'));
|
||||
console.log(palette.warn('No tasks found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
pending: chalk.gray,
|
||||
running: chalk.yellow,
|
||||
completed: chalk.green,
|
||||
failed: chalk.red,
|
||||
pending: palette.muted,
|
||||
running: palette.warn,
|
||||
completed: palette.ok,
|
||||
failed: palette.err,
|
||||
};
|
||||
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(palette.emph('\nTasks:'));
|
||||
for (const task of tasks) {
|
||||
const color = statusColors[task.status];
|
||||
const prompt = task.prompt.slice(0, 40) + (task.prompt.length > 40 ? '...' : '');
|
||||
console.log(` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`);
|
||||
console.log(
|
||||
` ${palette.info(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`
|
||||
);
|
||||
}
|
||||
|
||||
const counts = queue.getCount();
|
||||
console.log(chalk.bold('\nSummary:'));
|
||||
console.log(palette.emph('\nSummary:'));
|
||||
console.log(
|
||||
` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`
|
||||
);
|
||||
@@ -427,11 +442,11 @@ taskCmd
|
||||
const task = queue.getTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.log(chalk.red(`Task ${id} not found`));
|
||||
console.log(palette.err(`Task ${id} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nTask Details:'));
|
||||
console.log(palette.emph('\nTask Details:'));
|
||||
console.log(` ID: ${task.id}`);
|
||||
console.log(` Status: ${task.status}`);
|
||||
console.log(` Priority: ${task.priority}`);
|
||||
@@ -441,10 +456,10 @@ taskCmd
|
||||
console.log(` Session: ${task.assignedSessionId}`);
|
||||
}
|
||||
if (task.error) {
|
||||
console.log(` Error: ${chalk.red(task.error)}`);
|
||||
console.log(` Error: ${palette.err(task.error)}`);
|
||||
}
|
||||
if (task.output) {
|
||||
console.log(chalk.bold('\nOutput:'));
|
||||
console.log(palette.emph('\nOutput:'));
|
||||
console.log(task.output.slice(0, 500) + (task.output.length > 500 ? '...' : ''));
|
||||
}
|
||||
console.log('');
|
||||
@@ -457,9 +472,9 @@ taskCmd
|
||||
.action((id) => {
|
||||
const queue = getTaskQueue();
|
||||
if (queue.removeTask(id)) {
|
||||
console.log(chalk.green(`✓ Task removed: ${id}`));
|
||||
console.log(palette.ok(`✓ Task removed: ${id}`));
|
||||
} else {
|
||||
console.log(chalk.red(`Task ${id} not found`));
|
||||
console.log(palette.err(`Task ${id} not found`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -474,13 +489,13 @@ taskCmd
|
||||
|
||||
if (options.all) {
|
||||
count = queue.clearAll();
|
||||
console.log(chalk.green(`✓ Cleared ${count} tasks`));
|
||||
console.log(palette.ok(`✓ Cleared ${count} tasks`));
|
||||
} else if (options.failed) {
|
||||
count = queue.clearFailed();
|
||||
console.log(chalk.green(`✓ Cleared ${count} failed tasks`));
|
||||
console.log(palette.ok(`✓ Cleared ${count} failed tasks`));
|
||||
} else {
|
||||
count = queue.clearCompleted();
|
||||
console.log(chalk.green(`✓ Cleared ${count} completed tasks`));
|
||||
console.log(palette.ok(`✓ Cleared ${count} completed tasks`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -503,38 +518,38 @@ ralphCmd
|
||||
}
|
||||
|
||||
if (loop.isRunning()) {
|
||||
console.log(chalk.yellow('Ralph loop is already running'));
|
||||
console.log(palette.warn('Ralph loop is already running'));
|
||||
return;
|
||||
}
|
||||
|
||||
loop.on('taskAssigned', (taskId, sessionId) => {
|
||||
console.log(chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`));
|
||||
console.log(palette.info(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`));
|
||||
});
|
||||
|
||||
loop.on('taskCompleted', (taskId) => {
|
||||
console.log(chalk.green(`✓ Task ${taskId.slice(0, 8)} completed`));
|
||||
console.log(palette.ok(`✓ Task ${taskId.slice(0, 8)} completed`));
|
||||
});
|
||||
|
||||
loop.on('taskFailed', (taskId, error) => {
|
||||
console.log(chalk.red(`✗ Task ${taskId.slice(0, 8)} failed: ${error}`));
|
||||
console.log(palette.err(`✗ Task ${taskId.slice(0, 8)} failed: ${error}`));
|
||||
});
|
||||
|
||||
loop.on('stopped', () => {
|
||||
console.log(chalk.yellow('\nRalph loop stopped'));
|
||||
console.log(palette.warn('\nRalph loop stopped'));
|
||||
printStats(loop.getStats());
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
await loop.start();
|
||||
console.log(chalk.green('✓ Ralph loop started'));
|
||||
console.log(palette.ok('✓ Ralph loop started'));
|
||||
if (options.minHours) {
|
||||
console.log(` Minimum duration: ${options.minHours} hours`);
|
||||
}
|
||||
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||
console.log(palette.muted(' Press Ctrl+C to stop\n'));
|
||||
|
||||
// Keep process running
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\nStopping Ralph loop...'));
|
||||
console.log(palette.warn('\nStopping Ralph loop...'));
|
||||
loop.stop();
|
||||
});
|
||||
});
|
||||
@@ -545,11 +560,11 @@ ralphCmd
|
||||
.action(() => {
|
||||
const loop = getRalphLoop();
|
||||
if (!loop.isRunning()) {
|
||||
console.log(chalk.yellow('Ralph loop is not running'));
|
||||
console.log(palette.warn('Ralph loop is not running'));
|
||||
return;
|
||||
}
|
||||
loop.stop();
|
||||
console.log(chalk.green('✓ Ralph loop stopped'));
|
||||
console.log(palette.ok('✓ Ralph loop stopped'));
|
||||
});
|
||||
|
||||
ralphCmd
|
||||
@@ -562,9 +577,10 @@ ralphCmd
|
||||
});
|
||||
|
||||
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
|
||||
const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
|
||||
const statusColor =
|
||||
stats.status === 'running' ? palette.ok : stats.status === 'paused' ? palette.warn : palette.muted;
|
||||
|
||||
console.log(chalk.bold('\nRalph Loop Status:'));
|
||||
console.log(palette.emph('\nRalph Loop Status:'));
|
||||
console.log(` Status: ${statusColor(stats.status)}`);
|
||||
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
|
||||
if (stats.minDurationMs) {
|
||||
@@ -574,14 +590,14 @@ function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats'
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(palette.emph('\nTasks:'));
|
||||
console.log(` Pending: ${stats.pending}`);
|
||||
console.log(` Running: ${stats.running}`);
|
||||
console.log(` Completed: ${stats.completed} (${stats.tasksCompleted} this session)`);
|
||||
console.log(` Failed: ${stats.failed}`);
|
||||
console.log(` Generated: ${stats.tasksGenerated}`);
|
||||
|
||||
console.log(chalk.bold('\nSessions:'));
|
||||
console.log(palette.emph('\nSessions:'));
|
||||
console.log(` Active: ${stats.activeSessions}`);
|
||||
console.log(` Idle: ${stats.idleSessions}`);
|
||||
console.log(` Busy: ${stats.busySessions}`);
|
||||
@@ -695,20 +711,20 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nCodeman Status'));
|
||||
console.log('─'.repeat(40));
|
||||
console.log(heading('Codeman Status'));
|
||||
console.log(rule(40));
|
||||
|
||||
console.log(chalk.bold('\nWeb Server:'));
|
||||
console.log(heading('Web Server:'));
|
||||
if (probe.reachable) {
|
||||
const version = probe.version ? ` (v${probe.version})` : '';
|
||||
console.log(` Status: ${chalk.green('running')}${version} at ${probe.url}`);
|
||||
console.log(kv('Status', `${palette.ok('running')}${version} at ${probe.url}`));
|
||||
if (probe.authRequired) {
|
||||
console.log(chalk.gray(' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)'));
|
||||
console.log(palette.muted(' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)'));
|
||||
}
|
||||
} else {
|
||||
console.log(` Status: ${chalk.red('not reachable')} at ${candidates.join(' or ')}`);
|
||||
console.log(kv('Status', `${palette.err('not reachable')} at ${candidates.join(' or ')}`));
|
||||
console.log(
|
||||
chalk.gray(' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)')
|
||||
palette.muted(' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -716,26 +732,26 @@ program
|
||||
// as such, so the numbers are never silently a different thing.
|
||||
if (probe.sessions) {
|
||||
const live = probe.sessions;
|
||||
console.log(chalk.bold('\nSessions (live, from the server):'));
|
||||
console.log(` Total: ${live.length}`);
|
||||
console.log(` Idle: ${live.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${live.filter((s) => s.status === 'busy').length}`);
|
||||
console.log(heading('Sessions (live, from the server):'));
|
||||
console.log(kv('Total', String(live.length)));
|
||||
console.log(kv('Idle', String(live.filter((s) => s.status === 'idle').length)));
|
||||
console.log(kv('Busy', String(live.filter((s) => s.status === 'busy').length)));
|
||||
} else {
|
||||
const manager = getSessionManager();
|
||||
const storedValues = Object.values(manager.getStoredSessions());
|
||||
console.log(chalk.bold('\nSessions (from saved state):'));
|
||||
console.log(` Active: ${storedValues.filter((s) => s.status !== 'stopped').length}`);
|
||||
console.log(` Idle: ${storedValues.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${storedValues.filter((s) => s.status === 'busy').length}`);
|
||||
console.log(heading('Sessions (from saved state):'));
|
||||
console.log(kv('Active', String(storedValues.filter((s) => s.status !== 'stopped').length)));
|
||||
console.log(kv('Idle', String(storedValues.filter((s) => s.status === 'idle').length)));
|
||||
console.log(kv('Busy', String(storedValues.filter((s) => s.status === 'busy').length)));
|
||||
}
|
||||
|
||||
const taskCounts = getTaskQueue().getCount();
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(` Total: ${taskCounts.total}`);
|
||||
console.log(` Pending: ${taskCounts.pending}`);
|
||||
console.log(` Running: ${taskCounts.running}`);
|
||||
console.log(` Completed: ${taskCounts.completed}`);
|
||||
console.log(` Failed: ${taskCounts.failed}`);
|
||||
console.log(heading('Tasks:'));
|
||||
console.log(kv('Total', String(taskCounts.total)));
|
||||
console.log(kv('Pending', String(taskCounts.pending)));
|
||||
console.log(kv('Running', String(taskCounts.running)));
|
||||
console.log(kv('Completed', String(taskCounts.completed)));
|
||||
console.log(kv('Failed', String(taskCounts.failed)));
|
||||
console.log('');
|
||||
});
|
||||
|
||||
@@ -745,9 +761,17 @@ program
|
||||
.option('-f, --force', 'Skip confirmation')
|
||||
.action(async (options) => {
|
||||
if (!options.force) {
|
||||
console.log(chalk.yellow('This will stop all sessions and clear all state.'));
|
||||
console.log(chalk.yellow('Use --force to confirm.'));
|
||||
return;
|
||||
console.log(palette.warn('This will stop all sessions and clear all state.'));
|
||||
// Non-interactive callers keep the old refusal: a script piping into the
|
||||
// CLI must never be able to reset state by hanging on an unseen question.
|
||||
if (!isInteractive()) {
|
||||
console.log(palette.warn('Use --force to confirm.'));
|
||||
return;
|
||||
}
|
||||
if (!(await confirm('Reset all Codeman state?'))) {
|
||||
console.log(palette.muted('○ Cancelled, nothing was changed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const manager = getSessionManager();
|
||||
@@ -756,7 +780,7 @@ program
|
||||
await manager.stopAllSessions();
|
||||
store.reset();
|
||||
|
||||
console.log(chalk.green('✓ All state reset'));
|
||||
console.log(palette.ok('✓ All state reset'));
|
||||
});
|
||||
|
||||
// Shorthand commands at root level
|
||||
@@ -767,38 +791,45 @@ program
|
||||
.action(async (options) => {
|
||||
const manager = getSessionManager();
|
||||
const session = await manager.createSession(options.dir);
|
||||
console.log(chalk.green(`✓ Session started: ${session.id}`));
|
||||
console.log(palette.ok(`✓ Session started: ${session.id}`));
|
||||
});
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all sessions (shorthand)')
|
||||
.action(() => {
|
||||
const manager = getSessionManager();
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
.description('List active sessions (shorthand; `codeman session list` also shows stopped ones)')
|
||||
.action(() => printSessionList({ includeStored: false }));
|
||||
|
||||
if (sessions.length === 0 && Object.keys(stored).length === 0) {
|
||||
console.log(chalk.yellow('No sessions found'));
|
||||
// ============ TUI ============
|
||||
|
||||
program
|
||||
.command('tui')
|
||||
.argument('[n]', 'attach straight to the nth session of `codeman tui --list`')
|
||||
.description('Terminal dashboard for your sessions (the web UI remains the primary surface)')
|
||||
.option('-l, --list', 'Print the numbered session list and exit, instead of opening the dashboard')
|
||||
.action(async (position: string | undefined, options: { list?: boolean }) => {
|
||||
// Imported here, not at the top: the dashboard pulls in the whole TUI core,
|
||||
// and every other command would pay for it at startup.
|
||||
const { runTui, runTuiAttach, runTuiList } = await import('./tui/tui-app.js');
|
||||
|
||||
if (options.list) {
|
||||
process.exitCode = await runTuiList();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nActive Sessions:'));
|
||||
if (sessions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||
if (position !== undefined) {
|
||||
const n = Number.parseInt(position, 10);
|
||||
if (!Number.isSafeInteger(n) || n < 1) {
|
||||
console.error(palette.err(`"${position}" is not a session number.`));
|
||||
console.error(`Run ${palette.info('codeman tui --list')} to see them.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.exitCode = await runTuiAttach(n);
|
||||
return;
|
||||
}
|
||||
console.log('');
|
||||
// The dashboard owns the terminal until it quits; exiting explicitly keeps a
|
||||
// stray handle (a socket mid-close) from stranding the user's shell.
|
||||
process.exit(await runTui());
|
||||
});
|
||||
|
||||
// ============ Web / daemon / service Commands ============
|
||||
@@ -831,7 +862,7 @@ function toWebLaunchOptions(options: {
|
||||
}): WebLaunchOptions {
|
||||
const port = parseInt(options.port, 10);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
console.error(chalk.red(`✗ Invalid port: ${options.port}`));
|
||||
console.error(palette.err(`✗ Invalid port: ${options.port}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
@@ -852,11 +883,11 @@ function warnIfUnauthenticatedNetwork(launch: WebLaunchOptions): void {
|
||||
if (isLoopbackBindHost(launch.host)) return;
|
||||
if (isUnauthenticatedNetworkAcknowledged(launch.allowUnauthenticatedNetwork)) return;
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
palette.warn(
|
||||
`⚠ Binding ${launch.host} without CODEMAN_PASSWORD: anyone who can reach this port gets terminal control.`
|
||||
)
|
||||
);
|
||||
console.log(chalk.yellow(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.'));
|
||||
console.log(palette.warn(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.'));
|
||||
}
|
||||
|
||||
// Web interface command
|
||||
@@ -872,17 +903,18 @@ webCmd.action(async (options) => {
|
||||
const launch = toWebLaunchOptions(options);
|
||||
|
||||
if (options.stop) {
|
||||
const result = await stopDaemon(launch);
|
||||
// stopDaemon waits for the process to actually exit (up to 15s).
|
||||
const result = await withSpinner('Stopping Codeman...', () => stopDaemon(launch));
|
||||
if (result.ok && result.reason === 'not-running') {
|
||||
console.log(chalk.gray(`○ ${result.message}`));
|
||||
console.log(palette.muted(`○ ${result.message}`));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`));
|
||||
console.log(chalk.gray(' Your agents keep running in tmux.'));
|
||||
console.log(palette.ok(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`));
|
||||
console.log(palette.muted(' Your agents keep running in tmux.'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`✗ ${result.message ?? 'Could not stop the server'}`));
|
||||
console.error(palette.err(`✗ ${result.message ?? 'Could not stop the server'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -890,31 +922,33 @@ webCmd.action(async (options) => {
|
||||
const status = await daemonStatus(launch);
|
||||
if (status.responding) {
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(chalk.green(`✓ Responding at ${status.url}${version}`));
|
||||
console.log(palette.ok(`✓ Responding at ${status.url}${version}`));
|
||||
} else {
|
||||
console.log(chalk.yellow(`○ Nothing answering at ${status.url}`));
|
||||
console.log(palette.warn(`○ Nothing answering at ${status.url}`));
|
||||
}
|
||||
console.log(` Daemon pid: ${status.running ? chalk.green(String(status.pid)) : chalk.gray('not running')}`);
|
||||
console.log(chalk.gray(` Pidfile: ${status.pidFile}`));
|
||||
console.log(chalk.gray(` Log: ${status.logPath}`));
|
||||
console.log(kv('Daemon pid', status.running ? palette.ok(String(status.pid)) : palette.muted('not running'), 11));
|
||||
console.log(palette.muted(kv('Pidfile', status.pidFile, 11)));
|
||||
console.log(palette.muted(kv('Log', status.logPath, 11)));
|
||||
if (!status.running && status.responding) {
|
||||
console.log(chalk.gray(' (running, but not started with --daemon: probably a service or a foreground run)'));
|
||||
console.log(palette.muted(' (running, but not started with --daemon: probably a service or a foreground run)'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.daemon) {
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Starting Codeman in the background...'));
|
||||
const result = await startDaemon(launch);
|
||||
// The start polls /api/status for up to 30s; without this the shell just sits there.
|
||||
const result = await withSpinner('Starting Codeman in the background, waiting for it to answer...', () =>
|
||||
startDaemon(launch)
|
||||
);
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`));
|
||||
console.log(chalk.gray(` Logs: ${result.logPath}`));
|
||||
console.log(chalk.gray(' Stop it with: codeman web --stop'));
|
||||
console.log(chalk.gray(' Want it back after a reboot? codeman service install'));
|
||||
console.log(palette.ok(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`));
|
||||
console.log(palette.muted(` Logs: ${result.logPath}`));
|
||||
console.log(palette.muted(' Stop it with: codeman web --stop'));
|
||||
console.log(palette.muted(' Want it back after a reboot? codeman service install'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`\n✗ ${result.message ?? 'Failed to start'}`));
|
||||
console.error(palette.err(`\n✗ ${result.message ?? 'Failed to start'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -924,29 +958,29 @@ webCmd.action(async (options) => {
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(palette.info(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
// The server prints its own "running at" line (it also covers the daemon and
|
||||
// service launch paths), so this one used to be a duplicate of it.
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||
console.log(palette.muted(' Press Ctrl+C to stop\n'));
|
||||
|
||||
// Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`));
|
||||
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`Error during shutdown: ${getErrorMessage(err)}`));
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
@@ -954,7 +988,7 @@ webCmd.action(async (options) => {
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to start web server: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -970,20 +1004,23 @@ addWebLaunchOptions(
|
||||
).action(async (options) => {
|
||||
const launch = toWebLaunchOptions(options);
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Installing the Codeman service...'));
|
||||
|
||||
const result = await installService(launch);
|
||||
for (const warning of result.warnings ?? []) console.log(chalk.yellow(`⚠ ${warning}`));
|
||||
// Install polls the new unit's /api/status for up to 30s before it can honestly
|
||||
// report success, so the wait needs a visible heartbeat.
|
||||
const result = await withSpinner('Installing the Codeman service, waiting for it to answer...', () =>
|
||||
installService(launch)
|
||||
);
|
||||
for (const warning of result.warnings ?? []) console.log(palette.warn(`⚠ ${warning}`));
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
console.error(palette.err(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
console.log(chalk.gray(` Unit: ${result.unitPath}`));
|
||||
console.log(palette.ok(`✓ ${result.message}`));
|
||||
console.log(palette.muted(` Unit: ${result.unitPath}`));
|
||||
if (process.env.CODEMAN_PASSWORD) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
palette.warn(
|
||||
' Note: CODEMAN_PASSWORD was NOT copied into the unit file. Add it there yourself if the service needs auth.'
|
||||
)
|
||||
);
|
||||
@@ -996,10 +1033,10 @@ serviceCmd
|
||||
.action(() => {
|
||||
const result = uninstallService();
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
console.error(palette.err(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
console.log(palette.ok(`✓ ${result.message}`));
|
||||
});
|
||||
|
||||
addWebLaunchOptions(
|
||||
@@ -1007,15 +1044,15 @@ addWebLaunchOptions(
|
||||
).action(async (options) => {
|
||||
const status = await serviceStatus(toWebLaunchOptions(options));
|
||||
if (!status.kind) {
|
||||
console.log(chalk.yellow(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`));
|
||||
console.log(palette.warn(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`));
|
||||
return;
|
||||
}
|
||||
console.log(` Supervisor: ${status.kind} (${status.name})`);
|
||||
console.log(` Unit file: ${status.installed ? chalk.green(status.unitPath) : chalk.gray('not installed')}`);
|
||||
console.log(` Loaded: ${status.loaded ? chalk.green('yes') : chalk.gray('no')}`);
|
||||
console.log(` Unit file: ${status.installed ? palette.ok(status.unitPath) : palette.muted('not installed')}`);
|
||||
console.log(` Loaded: ${status.loaded ? palette.ok('yes') : palette.muted('no')}`);
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(
|
||||
` Responding: ${status.responding ? chalk.green(`yes at ${status.url}${version}`) : chalk.gray(`no at ${status.url}`)}`
|
||||
` Responding: ${status.responding ? palette.ok(`yes at ${status.url}${version}`) : palette.muted(`no at ${status.url}`)}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1085,7 +1122,7 @@ usersCmd
|
||||
.action(async (name, options) => {
|
||||
const { createUser, isValidUsername } = await import('./user-store.js');
|
||||
if (!isValidUsername(name)) {
|
||||
console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
console.error(palette.err('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
@@ -1096,18 +1133,18 @@ usersCmd
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
console.error(palette.err('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
console.error(chalk.red('✗ Password must be at least 8 characters'));
|
||||
console.error(palette.err('✗ Password must be at least 8 characters'));
|
||||
process.exit(1);
|
||||
}
|
||||
const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password });
|
||||
console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`));
|
||||
console.log(palette.ok(`✓ Created ${user.role} "${user.username}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -1126,14 +1163,14 @@ usersCmd
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
console.error(palette.err('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
await setPassword(name, password, { mustChangePassword: false });
|
||||
console.log(chalk.green(`✓ Password updated for "${name}"`));
|
||||
console.log(palette.ok(`✓ Password updated for "${name}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -1146,17 +1183,17 @@ usersCmd
|
||||
const { readUsers } = await import('./user-store.js');
|
||||
const users = await readUsers(true);
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.yellow('No users defined (run: codeman users add <name> --admin)'));
|
||||
console.log(palette.warn('No users defined (run: codeman users add <name> --admin)'));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('\nUsers:'));
|
||||
console.log(palette.emph('\nUsers:'));
|
||||
for (const u of users) {
|
||||
const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user ');
|
||||
const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled ');
|
||||
const role = u.role === 'admin' ? palette.accent('admin') : palette.info('user ');
|
||||
const state = u.disabled ? palette.err('disabled') : palette.ok('enabled ');
|
||||
const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`);
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? palette.muted(` [${flags}]`) : ''}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
@@ -1171,16 +1208,43 @@ usersCmd
|
||||
await deleteUser(name);
|
||||
if (options.deleteSpace) {
|
||||
await deleteUserSpace(name);
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" and their space`));
|
||||
console.log(palette.ok(`✓ Deleted user "${name}" and their space`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
console.log(palette.ok(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Missing REQUIRED tools are failures; a missing optional one or a skipped check
|
||||
* is just absence, so it stays muted rather than shouting red at everyone
|
||||
* without LibreOffice installed.
|
||||
*/
|
||||
function dependencyTone(result: ToolResult): Tone {
|
||||
if (result.status === 'ok') return 'ok';
|
||||
if (result.status === 'skipped') return 'idle';
|
||||
return result.required ? 'err' : 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The colorize hook `dependency-report.ts` was written for. Versions stay in the
|
||||
* default color (they are data, not a verdict); everything that IS a verdict is
|
||||
* painted, and the supporting detail is muted so the glyph column reads first.
|
||||
*/
|
||||
const DOCTOR_STYLE: ReportStyle = {
|
||||
title: (text) => palette.emph(text),
|
||||
heading: (text) => palette.emph(palette.info(text)),
|
||||
glyph: (result, glyph) => tint(dependencyTone(result), glyph),
|
||||
label: (text) => text,
|
||||
status: (result, text) => (result.status === 'ok' ? text : tint(dependencyTone(result), text)),
|
||||
path: (text) => palette.muted(text),
|
||||
meta: (text) => palette.muted(text),
|
||||
summary: (text) => palette.emph(text),
|
||||
};
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.alias('check-deps')
|
||||
@@ -1204,9 +1268,10 @@ program
|
||||
const results = checkAll(registry, host);
|
||||
|
||||
if (options.json) {
|
||||
// Raw JSON, never styled: this output is parsed, not read.
|
||||
console.log(JSON.stringify(renderJson(results, host.environment), null, 2));
|
||||
} else {
|
||||
console.log(renderTable(results, host.environment));
|
||||
console.log(renderTable(results, host.environment, DOCTOR_STYLE));
|
||||
}
|
||||
process.exit(computeExitCode(results));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
*/
|
||||
|
||||
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
|
||||
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
|
||||
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
|
||||
import { OMP_VERSION_REGEX } from '../utils/omp-cli-resolver.js';
|
||||
|
||||
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
||||
|
||||
@@ -139,6 +142,79 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
label: 'Grok CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Grok sessions'],
|
||||
// Version match required for the same reason as pi: `grok` has known squatters
|
||||
// (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a
|
||||
// bare `which grok` hit is not the coding agent. Both sides share
|
||||
// GROK_VERSION_REGEX, so the doctor and the run mode cannot drift.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['grok'],
|
||||
versionArg: '--version',
|
||||
versionRegex: GROK_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dsh',
|
||||
label: 'DeepSeek Harness CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['DeepSeek sessions'],
|
||||
// Version match required, and for a sharper reason than pi or grok: `dsh` is
|
||||
// not merely a squattable npm name, it is an existing Debian program
|
||||
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
|
||||
// demands the harness's own help banner before it will point a spawn line at
|
||||
// a candidate; the doctor is advisory and settles for the shared
|
||||
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
|
||||
// though the resolver is the stricter of the pair about IDENTITY.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['dsh'],
|
||||
versionArg: '--version',
|
||||
versionRegex: DEEPSEEK_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'omp',
|
||||
label: 'OMP CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['OMP sessions'],
|
||||
// Same version-match discipline as pi: `omp` is a short generic name, so a
|
||||
// `which omp` hit alone is not the coding agent. Both sides share
|
||||
// OMP_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
|
||||
// the user opposite things about the same binary.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['omp'],
|
||||
versionArg: '--version',
|
||||
versionRegex: OMP_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' },
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -40,6 +40,21 @@ const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : '';
|
||||
/** Default tmux socket for this instance. `CODEMAN_TMUX_SOCKET` still overrides. */
|
||||
export const DEFAULT_TMUX_SOCKET = `codeman${INSTANCE_SUFFIX}`;
|
||||
|
||||
/** Characters tmux accepts in a `-L` socket name. */
|
||||
export const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* This instance's tmux socket: the `CODEMAN_TMUX_SOCKET` override when it is a
|
||||
* safe name, else the instance default. Every process that runs `tmux -L` has
|
||||
* to resolve it through here (the server via TmuxManager, the TUI for its
|
||||
* degraded-mode listing), or a beta instance ends up driving prod's sessions.
|
||||
*/
|
||||
export function resolveTmuxSocketName(): string {
|
||||
const raw = process.env.CODEMAN_TMUX_SOCKET;
|
||||
if (raw !== undefined && SAFE_TMUX_SOCKET_PATTERN.test(raw)) return raw;
|
||||
return DEFAULT_TMUX_SOCKET;
|
||||
}
|
||||
|
||||
let _ensured = false;
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
|
||||
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
|
||||
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
|
||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live.
|
||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is wired.
|
||||
* tmux <3.7 applies it to new panes; tmux 3.7+ can also resize live panes.
|
||||
* All values remain env- and settings-overridable and bounds-clamped via
|
||||
* resolveTerminalHistoryConfig().
|
||||
*/
|
||||
|
||||
@@ -48,7 +48,9 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
||||
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
|
||||
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
|
||||
* is NOT a clamp.
|
||||
* Codex and antigravity need nothing here: their absent config already spawns safe.
|
||||
* Codex, antigravity, grok and deepseek need nothing here: their absent config already spawns safe
|
||||
* (grok's bare spawn is its own ask-mode default and deepseek's omits DSH_PERMISSION_MODE
|
||||
* entirely, leaving the harness on workspace-write, which asks; both switches are only ever sent).
|
||||
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
|
||||
*/
|
||||
export function clampCronExternalCliConfigs(
|
||||
@@ -393,11 +395,23 @@ export class CronService {
|
||||
let session: Session;
|
||||
try {
|
||||
const mode = job.agentType;
|
||||
// Same two-part availability gate the HTTP create paths run: `dsh` is a
|
||||
// profile LAUNCHER, so without this a job on a box with only the stock
|
||||
// web/headless profiles spawns a bare `dsh` that boots a profile unable
|
||||
// to drive a pane, and the prompt is typed into a logging server or a
|
||||
// dead pane instead of failing the run with the actionable message.
|
||||
if (mode === 'deepseek') {
|
||||
const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js');
|
||||
const launchError = resolveDeepSeekLaunchError();
|
||||
if (launchError) return this.failRun(job, run, launchError);
|
||||
}
|
||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||
const modelConfig = await this.deps.getModelConfig();
|
||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// DeepSeek's model is a composition entry in the profile's config tree,
|
||||
// not a session flag — mirror the HTTP routes' exclusion.
|
||||
const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// Section 6.3: materialize the safe default for a non-granted owner (see
|
||||
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
|
||||
// spawn default is what would otherwise apply).
|
||||
@@ -425,7 +439,7 @@ export class CronService {
|
||||
piConfig,
|
||||
owner: job.owner,
|
||||
});
|
||||
this.deps.addSession(session);
|
||||
await this.deps.addSession(session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.deps.persistSessionState(session);
|
||||
await this.deps.setupSessionListeners(session);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @fileoverview The DeepSeek Harness -> Codeman status bridge.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* Every external CLI mode before this one (opencode, codex, gemini, antigravity,
|
||||
* pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a
|
||||
* turn ended. Claude is the exception, because Claude Code fires real hooks. The
|
||||
* DeepSeek Harness TUI gives us a third option, and a much better one than
|
||||
* guessing: the community terminal front door already reports its own lifecycle
|
||||
* to an owning supervisor, and it does so through a fully GENERIC, env-var-gated
|
||||
* contract it inherited from Herdr (herdr.dev).
|
||||
*
|
||||
* When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set,
|
||||
* the TUI shells out on every state change:
|
||||
*
|
||||
* "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
|
||||
* --source custom:dsh-tui --agent dsh-tui \
|
||||
* --state idle|working|blocked [--message <text>] --seq <n>
|
||||
*
|
||||
* and treats exit code 0 as "delivered" (retrying with backoff otherwise). So
|
||||
* Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE
|
||||
* idle/working/blocked signals for dsh sessions: real respawn triggers, real
|
||||
* `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items,
|
||||
* on par with Claude's hooks rather than with output stabilization.
|
||||
*
|
||||
* This is an interface implementation, not an impersonation: we implement the
|
||||
* one verb (`pane report-agent`) that the contract defines, and nothing on the
|
||||
* machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own
|
||||
* script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know
|
||||
* a supervisor is present; a supervisor IS present, it is Codeman.
|
||||
*
|
||||
* ## Why it is generated rather than committed
|
||||
*
|
||||
* The shim must be an executable file at a stable absolute path in every
|
||||
* install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman`
|
||||
* (where `files` ships only `dist` plus two named scripts), and any
|
||||
* `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes
|
||||
* one code path cover all of them, single-sources the content here in TS, and
|
||||
* follows the precedent of `self-update-runner.sh`. It is rewritten whenever the
|
||||
* embedded version marker changes, so an upgraded Codeman refreshes a stale shim
|
||||
* without the user knowing it exists.
|
||||
*
|
||||
* @module deepseek-status-shim
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
|
||||
/**
|
||||
* Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated
|
||||
* file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written
|
||||
* by an older Codeman and rewrite only when needed (rather than rewriting on
|
||||
* every session create, or — worse — leaving a stale one in place forever).
|
||||
*/
|
||||
const SHIM_VERSION = 3;
|
||||
const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`;
|
||||
|
||||
/**
|
||||
* Mapping from the harness's three lifecycle states to Codeman hook events.
|
||||
*
|
||||
* - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool
|
||||
* approval or an `ask_user_question` questionnaire is on screen, which is
|
||||
* exactly the red "needs you" alert and an answerable Approvals Inbox item.
|
||||
* - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the
|
||||
* wait endpoints care about.
|
||||
* - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from
|
||||
* PTY output well enough on its own, but the event is what RESOLVES a pending
|
||||
* approval when the user answers a dialog in the terminal instead of in the
|
||||
* inbox. Without it a dsh session's red alert would survive until the next
|
||||
* `stop`, which is the exact stuck-alert bug the claude path already had to
|
||||
* fix once (and the pane-capture staleness sweep that fixed it there is
|
||||
* Claude-dialog-shaped, so it cannot help here).
|
||||
*/
|
||||
export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly<Record<string, string>> = Object.freeze({
|
||||
idle: 'stop',
|
||||
blocked: 'permission_prompt',
|
||||
working: 'agent_working',
|
||||
});
|
||||
|
||||
/**
|
||||
* The generated script.
|
||||
*
|
||||
* Constraints it must satisfy, each learned from an existing Codeman hook bug:
|
||||
* - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on
|
||||
* `--https`/tailscale installs, so certificate verification is disabled for
|
||||
* the request. Without this the whole bridge dies silently, exactly as the
|
||||
* claude hook curls did before they grew `-k`.
|
||||
* - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in,
|
||||
* so rotation needs no respawn and the value never lands on a command line.
|
||||
* - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with
|
||||
* backoff, so transport failures self-heal, but an unknown verb or an
|
||||
* unmapped state exits 0 to avoid a pointless retry storm over something that
|
||||
* will never succeed.
|
||||
* - **Timeout**: bounded below the caller's own 2s budget, so we lose the race
|
||||
* deliberately rather than being killed mid-flight.
|
||||
*/
|
||||
const SHIM_SOURCE = `#!/usr/bin/env node
|
||||
// ${SHIM_MARKER}
|
||||
// GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts
|
||||
// whenever its version marker changes.
|
||||
//
|
||||
// Implements the one verb the DeepSeek Harness TUI's supervisor contract uses:
|
||||
// pane report-agent <paneId> --state <idle|working|blocked> [--message <t>] ...
|
||||
// and forwards it to this Codeman instance as a hook event.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)}
|
||||
const TIMEOUT_MS = 1500
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const flag = (name) => {
|
||||
const i = argv.indexOf(name)
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined
|
||||
}
|
||||
|
||||
// Unknown verb: succeed silently. Retrying could never make it succeed, and a
|
||||
// non-zero exit here would make the caller retry four times per state change.
|
||||
if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0)
|
||||
|
||||
const event = STATE_TO_EVENT[String(flag('--state') ?? '')]
|
||||
if (!event) process.exit(0)
|
||||
|
||||
// The pane id we hand the TUI IS the Codeman session id, but prefer the ambient
|
||||
// env: it is set by the same code that set HERDR_PANE_ID, so a TUI that mangles,
|
||||
// truncates or re-uses the pane argument still reports against the right session.
|
||||
// NOT a security boundary, and do not read it as one: the agent runs IN this pane
|
||||
// and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes.
|
||||
// That buys it nothing it did not already have, since the hook-secret file is
|
||||
// readable from the same pane and any process there can POST /api/hook-event
|
||||
// directly. Attribution here is about accidents, not adversaries.
|
||||
const sessionId = process.env.CODEMAN_SESSION_ID || argv[2]
|
||||
const apiUrl = process.env.CODEMAN_API_URL
|
||||
if (!sessionId || !apiUrl) process.exit(1)
|
||||
|
||||
let secret = ''
|
||||
try {
|
||||
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
|
||||
} catch {
|
||||
// Missing file: the loopback bypass still applies when no tunnel is running.
|
||||
}
|
||||
|
||||
// The contract's ordering token: the TUI retries failed deliveries with
|
||||
// backoff, so a stale report can land AFTER a newer one. Forwarded so the
|
||||
// server can drop out-of-order arrivals instead of, say, resolving an
|
||||
// approval with a retried 'working' while the harness sits blocked.
|
||||
const seq = Number(flag('--seq'))
|
||||
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
sessionId,
|
||||
data: {
|
||||
source: 'dsh-status-shim',
|
||||
agent: flag('--agent') || 'dsh',
|
||||
...(Number.isFinite(seq) ? { seq } : {}),
|
||||
...(flag('--message') ? { message: flag('--message') } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL('/api/hook-event', apiUrl)
|
||||
} catch {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const transport = url.protocol === 'https:' ? https : http
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'X-Codeman-Hook-Secret': secret,
|
||||
},
|
||||
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
const status = res.statusCode ?? 0
|
||||
// 2xx: delivered. 4xx: PERMANENT — a 401 (missing/rotated secret) or 429
|
||||
// can never be fixed by retrying, and each retry feeds the auth-failure
|
||||
// rate-limit bucket, so a single misconfigured dsh session could 429 the
|
||||
// hook endpoint for the whole instance (killing every claude session's
|
||||
// real hooks). Exit 0 so the TUI does not retry; only transport errors
|
||||
// and 5xx stay retryable.
|
||||
process.exit(status >= 200 && status < 500 ? 0 : 1)
|
||||
}
|
||||
)
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
process.exit(1)
|
||||
})
|
||||
req.on('error', () => process.exit(1))
|
||||
req.end(body)
|
||||
`;
|
||||
|
||||
/** Absolute path of the generated shim for this instance. */
|
||||
export function deepSeekStatusShimPath(): string {
|
||||
return dataPath('dsh-status-shim.mjs');
|
||||
}
|
||||
|
||||
let ensuredThisProcess = false;
|
||||
|
||||
/**
|
||||
* Write the shim if it is missing or stale, and return its path.
|
||||
*
|
||||
* Idempotent and cheap: after the first call in a process it does nothing, and
|
||||
* even the first call only rewrites when the on-disk marker differs. Never
|
||||
* throws — a data dir that cannot be written is a degraded status bridge, not a
|
||||
* failed session start, so callers fall back to output-stabilization readiness
|
||||
* by receiving null.
|
||||
*/
|
||||
export function ensureDeepSeekStatusShim(): string | null {
|
||||
const path = deepSeekStatusShimPath();
|
||||
if (ensuredThisProcess) return path;
|
||||
try {
|
||||
let current = '';
|
||||
try {
|
||||
current = readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
// Missing — fall through to the write.
|
||||
}
|
||||
if (!current.includes(SHIM_MARKER)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// Temp + rename, not a plain write: the TUI can be executing this exact
|
||||
// path at the moment an upgraded Codeman refreshes it (every state change
|
||||
// runs it, and session create is when the rewrite happens), and a reader
|
||||
// that catches a half-written file gets a syntax error, exits non-zero,
|
||||
// and is retried four times per state change for a file that will never
|
||||
// parse. rename(2) is atomic within the directory, so a concurrent exec
|
||||
// sees either the old shim or the new one, never a truncated one.
|
||||
// Same reasoning as the state-store writes; pid-suffixed so two instances
|
||||
// sharing a data dir cannot collide on the temp name.
|
||||
const tempPath = `${path}.${process.pid}.tmp`;
|
||||
try {
|
||||
writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 });
|
||||
// The mode argument only applies when writeFileSync CREATES the file, so
|
||||
// a leftover temp from a crashed run would keep its old permissions.
|
||||
chmodSync(tempPath, 0o700);
|
||||
renameSync(tempPath, path);
|
||||
} catch (err) {
|
||||
rmSync(tempPath, { force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// Re-assert the mode even when the content matched: a shim that lost its
|
||||
// executable bit (a restored backup, a copied data dir) would make every
|
||||
// report fail, and the TUI would retry four times per state change forever.
|
||||
chmodSync(path, 0o700);
|
||||
ensuredThisProcess = true;
|
||||
return path;
|
||||
} catch (err) {
|
||||
console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */
|
||||
export function resetDeepSeekStatusShimForTest(): void {
|
||||
ensuredThisProcess = false;
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* @fileoverview Reading a DeepSeek Harness (`dsh`) session transcript off disk.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* `GET /api/sessions/:id/last-response` is how an agent (and the Response
|
||||
* Viewer) reads what a worker actually said. For Claude it comes from
|
||||
* `~/.claude/projects/**`, for Codex from `~/.codex/sessions/**`, and for every
|
||||
* other external CLI it comes from segmenting the terminal buffer, because
|
||||
* those CLIs write nothing a reader could open.
|
||||
*
|
||||
* dsh is not in that last group: it writes a complete, structured JSONL
|
||||
* transcript per session. Falling back to the pane for it was measurably wrong
|
||||
* rather than merely coarse — dsh-TUI paints a full-screen splash, so the pane
|
||||
* segmenter answered a `last-response` call for a fresh dsh session with the
|
||||
* ASCII-art logo:
|
||||
*
|
||||
* {"text":"✦dsh-TUI v0.8.8█▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▄█▀▀▀▀…","hasContext":true}
|
||||
*
|
||||
* which an agent polling for a worker's answer reads as an answer. This module
|
||||
* is the real source: it locates the session's transcript, decodes it, and
|
||||
* returns the last turn's text.
|
||||
*
|
||||
* ## The three things that make dsh transcripts unlike codex rollouts
|
||||
*
|
||||
* **1. One zstd FRAME per append, not one zstd stream.** The file is
|
||||
* `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into
|
||||
* its own frame and writing it at the end. `zstd -dc` handles that (frames
|
||||
* concatenate by definition), but Node's `zlib.zstdDecompress()` and
|
||||
* `createZstdDecompress()` both stop at the first frame end: measured on a real
|
||||
* 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned
|
||||
* 43,747 bytes / 56 lines. That is a silent truncation to the session header —
|
||||
* every call would have reported "no answer yet" forever. `decodeZstdFrames()`
|
||||
* below walks the frame headers itself and decompresses each frame, and
|
||||
* `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures.
|
||||
*
|
||||
* **2. The user's prompts are mixed with injected context.** Every turn also
|
||||
* writes a `user/message` whose source is a plugin (the runtime-context
|
||||
* snapshot: sandbox policy, approval policy, cwd). Those are `source.kind ===
|
||||
* 'plugin'`; a real prompt is `source.kind === 'user'`. Rendering the plugin
|
||||
* ones would show the agent its own boilerplate back as the user's words.
|
||||
*
|
||||
* **3. A failed turn is not an empty turn.** `turn/end` carries
|
||||
* `reason.kind === 'error'` with the provider's message. Returning `""` there
|
||||
* makes an agent poll `last-response` fifteen times and conclude the worker
|
||||
* never answered, when the truth ("the provider rejected the request") was on
|
||||
* disk the whole time. A turn that ends in an error and produced no text
|
||||
* answers with that error, prefixed so it can never be mistaken for the model's
|
||||
* own words.
|
||||
*
|
||||
* Verified against `dsh 0.1.1-rc.2` + `@deepseek-harness-tui/dsh-tui 0.8.8`.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as zlib from 'node:zlib';
|
||||
|
||||
/**
|
||||
* One rendered block, in the shape the Response Viewer already speaks (see
|
||||
* `web/response-viewer-transcript.ts`). Imported as a type only — this module
|
||||
* must stay usable from the session layer without dragging web/ into it.
|
||||
*/
|
||||
export interface DeepSeekTranscriptBlock {
|
||||
kind: 'prompt' | 'response' | 'status' | 'tool';
|
||||
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DeepSeekTranscriptResult {
|
||||
/** Last turn's answer (or its error, prefixed). Empty before the first turn. */
|
||||
text: string;
|
||||
/** ISO timestamp of the event `text` came from, or '' when unknown. */
|
||||
timestamp: string;
|
||||
/** Rendered blocks, oldest first. Only built when the caller asks for them. */
|
||||
blocks: DeepSeekTranscriptBlock[];
|
||||
/** dsh's own session id, from the header line. */
|
||||
sessionId?: string;
|
||||
/** Workspace the harness recorded for the session. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* zstd decompression is a RUNTIME capability here, not an import.
|
||||
*
|
||||
* Node grew `zlib` zstd support in 22.15 (and `@types/node` still does not
|
||||
* declare it), while Codeman's floor is Node 22.0. So it is resolved through a
|
||||
* narrow cast and checked before use: on an older 22.x a dsh session keeps the
|
||||
* pane-segmenter behaviour it had before this module existed instead of
|
||||
* throwing on every `last-response` call.
|
||||
*/
|
||||
type ZstdDecompressSync = (buf: Buffer) => Buffer;
|
||||
const zstdDecompressSync: ZstdDecompressSync | undefined = (
|
||||
zlib as unknown as { zstdDecompressSync?: ZstdDecompressSync }
|
||||
).zstdDecompressSync;
|
||||
|
||||
/** Whether this Node can decode the compressed transcripts dsh writes. */
|
||||
export function zstdSupported(): boolean {
|
||||
return typeof zstdDecompressSync === 'function';
|
||||
}
|
||||
|
||||
/** zstd frame magic (RFC 8878 §3.1.1). */
|
||||
const ZSTD_MAGIC = 0xfd2fb528;
|
||||
/** Skippable-frame magic range: 0x184D2A50..0x184D2A5F. */
|
||||
const ZSTD_SKIPPABLE_LO = 0x184d2a50;
|
||||
const ZSTD_SKIPPABLE_HI = 0x184d2a5f;
|
||||
|
||||
const DID_FIELD_SIZE = [0, 1, 2, 4];
|
||||
const FCS_FIELD_SIZE = [0, 2, 4, 8];
|
||||
|
||||
/**
|
||||
* Byte ranges of the zstd frames in `buf`, in order.
|
||||
*
|
||||
* Walks frame headers and block headers only — no decompression — so the cost
|
||||
* is proportional to the number of blocks, not to the content. Stops (rather
|
||||
* than throws) at the first thing it cannot parse, so a transcript still being
|
||||
* appended to mid-write yields every whole frame before the torn tail instead
|
||||
* of failing the whole read.
|
||||
*
|
||||
* ⚠️ Splitting on the magic bytes instead would be wrong: the 4-byte sequence
|
||||
* can occur inside compressed data, and a false split corrupts everything after
|
||||
* it. The block walk is what makes the boundaries exact.
|
||||
*/
|
||||
export function zstdFrameRanges(buf: Buffer): Array<[number, number]> {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 4 <= buf.length) {
|
||||
const magic = buf.readUInt32LE(offset);
|
||||
|
||||
if (magic >= ZSTD_SKIPPABLE_LO && magic <= ZSTD_SKIPPABLE_HI) {
|
||||
if (offset + 8 > buf.length) break;
|
||||
const end = offset + 8 + buf.readUInt32LE(offset + 4);
|
||||
if (end > buf.length || end <= offset) break;
|
||||
offset = end;
|
||||
continue;
|
||||
}
|
||||
if (magic !== ZSTD_MAGIC) break;
|
||||
|
||||
let p = offset + 4;
|
||||
if (p >= buf.length) break;
|
||||
|
||||
const descriptor = buf[p] as number;
|
||||
p += 1;
|
||||
const fcsFlag = descriptor >> 6;
|
||||
const singleSegment = (descriptor >> 5) & 1;
|
||||
const hasChecksum = (descriptor >> 2) & 1;
|
||||
const dictIdFlag = descriptor & 3;
|
||||
|
||||
if (!singleSegment) p += 1; // window descriptor
|
||||
p += DID_FIELD_SIZE[dictIdFlag] as number;
|
||||
// FCS is absent for flag 0 UNLESS Single_Segment is set, where it is 1 byte.
|
||||
p += fcsFlag === 0 ? (singleSegment ? 1 : 0) : (FCS_FIELD_SIZE[fcsFlag] as number);
|
||||
if (p > buf.length) break;
|
||||
|
||||
let lastBlock = false;
|
||||
let torn = false;
|
||||
while (!lastBlock) {
|
||||
if (p + 3 > buf.length) {
|
||||
torn = true;
|
||||
break;
|
||||
}
|
||||
const header = (buf[p] as number) | ((buf[p + 1] as number) << 8) | ((buf[p + 2] as number) << 16);
|
||||
p += 3;
|
||||
lastBlock = (header & 1) === 1;
|
||||
const blockType = (header >> 1) & 3;
|
||||
const blockSize = header >> 3;
|
||||
if (blockType === 3) {
|
||||
torn = true; // reserved: refuse rather than guess
|
||||
break;
|
||||
}
|
||||
p += blockType === 1 ? 1 : blockSize; // RLE stores a single byte
|
||||
if (p > buf.length) {
|
||||
torn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (torn) break;
|
||||
|
||||
if (hasChecksum) p += 4;
|
||||
if (p > buf.length) break;
|
||||
|
||||
ranges.push([offset, p]);
|
||||
offset = p;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a possibly multi-frame zstd buffer. A buffer that does not start with
|
||||
* a zstd magic is passed through unchanged, which is what lets the same reader
|
||||
* open a plain `session.jsonl` (dsh writes one when compression is off).
|
||||
*
|
||||
* A frame that fails to decompress truncates the decode THERE rather than
|
||||
* failing it: everything decoded before it is kept, so a half-written tail
|
||||
* frame does not cost the caller the whole conversation. (Not "skipped" — a
|
||||
* frame after a corrupt one is never reached, which is the safe reading: dsh
|
||||
* appends, so a bad frame means everything after it is suspect too.)
|
||||
*/
|
||||
export function decodeZstdFrames(buf: Buffer): string {
|
||||
if (buf.length < 4) return buf.toString('utf8');
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== ZSTD_MAGIC && (magic < ZSTD_SKIPPABLE_LO || magic > ZSTD_SKIPPABLE_HI)) {
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
|
||||
if (!zstdDecompressSync) return '';
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
for (const [start, end] of zstdFrameRanges(buf)) {
|
||||
try {
|
||||
parts.push(zstdDecompressSync(buf.subarray(start, end)));
|
||||
} catch {
|
||||
// Torn or corrupt frame: keep what decoded before it.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Buffer.concat(parts).toString('utf8');
|
||||
}
|
||||
|
||||
interface DshEvent {
|
||||
type?: string;
|
||||
seq?: number | null;
|
||||
time?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leaked reasoning prefix.
|
||||
*
|
||||
* Some providers stream reasoning into the same text block and close it with
|
||||
* `</think>` without ever opening it (measured on a local deepseek-v4-flash
|
||||
* route: `"I'll read the file first.</think>\n\nThe add function is…"`). The
|
||||
* closing tag is the only reliable boundary, so everything up to the LAST one
|
||||
* goes. A block with no tag is returned untouched.
|
||||
*/
|
||||
function stripReasoningPrefix(text: string): string {
|
||||
const close = text.lastIndexOf('</think>');
|
||||
return close === -1 ? text : text.slice(close + '</think>'.length);
|
||||
}
|
||||
|
||||
/** `stripReasoning` is for ASSISTANT content only: a user prompt containing a
|
||||
* literal `</think>` (someone pasting a transcript, say) must render whole. */
|
||||
function textOfContent(content: unknown, stripReasoning = true): string {
|
||||
const parts: string[] = [];
|
||||
for (const entry of asArray(content)) {
|
||||
const block = asRecord(entry);
|
||||
if (!block) continue;
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(stripReasoning ? stripReasoningPrefix(block.text) : block.text);
|
||||
}
|
||||
}
|
||||
return parts.join('').trim();
|
||||
}
|
||||
|
||||
function toolCallsOfContent(content: unknown): string[] {
|
||||
const calls: string[] = [];
|
||||
for (const entry of asArray(content)) {
|
||||
const block = asRecord(entry);
|
||||
if (!block || block.type !== 'tool-call') continue;
|
||||
const name = typeof block.name === 'string' ? block.name : 'tool';
|
||||
const args = typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments ?? {});
|
||||
calls.push(`${name}(${args})`);
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** Flatten a `tool/result` message down to its text payload. */
|
||||
function textOfToolResult(message: unknown): string {
|
||||
const parts: string[] = [];
|
||||
for (const entry of asArray(asRecord(message)?.content)) {
|
||||
const block = asRecord(entry);
|
||||
if (!block) continue;
|
||||
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
||||
if (block.type === 'tool-result') {
|
||||
for (const inner of asArray(block.content)) {
|
||||
const innerBlock = asRecord(inner);
|
||||
if (innerBlock?.type === 'text' && typeof innerBlock.text === 'string') parts.push(innerBlock.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
function isoTime(time: unknown): string {
|
||||
return typeof time === 'number' && Number.isFinite(time) ? new Date(time).toISOString() : '';
|
||||
}
|
||||
|
||||
interface TurnAccumulator {
|
||||
/** Finalized `assistant/message` text, in step order. */
|
||||
finalized: Map<number, string>;
|
||||
/** Steps that produced a finalized message AT ALL. ⚠️ Not the same as a
|
||||
* non-empty entry in `finalized`: a step whose whole reply was reasoning
|
||||
* strips to `''`, and without this the deltas — which are NOT stripped at
|
||||
* write time — would be resurrected in its place, putting the model's raw
|
||||
* `</think>` monologue in front of the caller (measured). */
|
||||
finalizedSteps: Set<number>;
|
||||
/** Streamed deltas per step, used only where no finalized message landed. */
|
||||
streamed: Map<number, string>;
|
||||
/** Step order as encountered, so a reply reads in the order it was produced. */
|
||||
steps: number[];
|
||||
timestamp: string;
|
||||
/** Pre-rendered "Turn error: …" / "Turn ended: …" line, when the turn did not
|
||||
* end with `completed`. */
|
||||
ending?: string;
|
||||
}
|
||||
|
||||
function ensureStep(turn: TurnAccumulator, step: number): void {
|
||||
if (!turn.steps.includes(step)) turn.steps.push(step);
|
||||
}
|
||||
|
||||
function turnText(turn: TurnAccumulator): string {
|
||||
const parts: string[] = [];
|
||||
for (const step of turn.steps) {
|
||||
// Deltas are only consulted for a step the model never finalized — a step
|
||||
// that has both would otherwise render its text twice.
|
||||
const text = turn.finalizedSteps.has(step)
|
||||
? (turn.finalized.get(step) ?? '')
|
||||
: stripReasoningPrefix(turn.streamed.get(step) ?? '');
|
||||
if (text.trim()) parts.push(text.trim());
|
||||
}
|
||||
return parts.join('\n\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decoded dsh transcript.
|
||||
*
|
||||
* `text` is the LAST TURN's answer, not the last assistant message anywhere in
|
||||
* the file: a turn that errored after an earlier turn answered must not hand
|
||||
* back the earlier turn's text as though it were this turn's reply.
|
||||
*/
|
||||
export function parseDeepSeekTranscript(raw: string, options: { blocks?: boolean } = {}): DeepSeekTranscriptResult {
|
||||
const wantBlocks = options.blocks === true;
|
||||
const blocks: DeepSeekTranscriptBlock[] = [];
|
||||
const turns = new Map<number, TurnAccumulator>();
|
||||
const turnOrder: number[] = [];
|
||||
let sessionId: string | undefined;
|
||||
let cwd: string | undefined;
|
||||
|
||||
const getTurn = (n: number): TurnAccumulator => {
|
||||
let turn = turns.get(n);
|
||||
if (!turn) {
|
||||
turn = { finalized: new Map(), finalizedSteps: new Set(), streamed: new Map(), steps: [], timestamp: '' };
|
||||
turns.set(n, turn);
|
||||
turnOrder.push(n);
|
||||
}
|
||||
return turn;
|
||||
};
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let event: DshEvent;
|
||||
try {
|
||||
event = JSON.parse(line) as DshEvent;
|
||||
} catch {
|
||||
continue; // a torn tail line, or a frame we could not decode
|
||||
}
|
||||
const data = asRecord(event.data) ?? {};
|
||||
const turnNo = typeof data.turn === 'number' ? data.turn : 0;
|
||||
const stepNo = typeof data.step === 'number' ? data.step : 0;
|
||||
|
||||
switch (event.type) {
|
||||
case 'session': {
|
||||
const header = event as unknown as Record<string, unknown>;
|
||||
if (typeof header.id === 'string') sessionId = header.id;
|
||||
if (typeof header.cwd === 'string') cwd = header.cwd;
|
||||
break;
|
||||
}
|
||||
case 'user/message': {
|
||||
// ⚠️ Only a real prompt. The plugin-sourced twin is the runtime-context
|
||||
// snapshot dsh injects every turn (sandbox policy, approvals, cwd).
|
||||
if (asRecord(data.source)?.kind !== 'user') break;
|
||||
if (!wantBlocks) break;
|
||||
const text = textOfContent(data.content, false);
|
||||
if (text) blocks.push({ kind: 'prompt', label: 'Prompt', role: 'user', text });
|
||||
break;
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const message = asRecord(data.message);
|
||||
const turn = getTurn(turnNo);
|
||||
ensureStep(turn, stepNo);
|
||||
const text = textOfContent(message?.content);
|
||||
if (message) turn.finalizedSteps.add(stepNo);
|
||||
if (text) {
|
||||
turn.finalized.set(stepNo, text);
|
||||
turn.timestamp = isoTime(event.time) || turn.timestamp;
|
||||
if (wantBlocks) blocks.push({ kind: 'response', label: 'Response', role: 'assistant', text });
|
||||
}
|
||||
if (wantBlocks) {
|
||||
for (const call of toolCallsOfContent(message?.content)) {
|
||||
blocks.push({ kind: 'tool', label: 'Tool', role: 'assistant', text: call });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const chunk = asRecord(data.chunk);
|
||||
if (chunk?.type !== 'text-delta' || typeof chunk.text !== 'string') break;
|
||||
const turn = getTurn(turnNo);
|
||||
ensureStep(turn, stepNo);
|
||||
turn.streamed.set(stepNo, (turn.streamed.get(stepNo) ?? '') + chunk.text);
|
||||
break;
|
||||
}
|
||||
case 'text-chunks': {
|
||||
// The batched form of the same deltas (dsh coalesces once a stream gets
|
||||
// going). ⚠️ These carry `seq: null`, so file order is the only order.
|
||||
const turn = getTurn(turnNo);
|
||||
ensureStep(turn, stepNo);
|
||||
const texts = asArray(data.texts)
|
||||
.filter((t): t is string => typeof t === 'string')
|
||||
.join('');
|
||||
if (texts) turn.streamed.set(stepNo, (turn.streamed.get(stepNo) ?? '') + texts);
|
||||
break;
|
||||
}
|
||||
case 'tool/result': {
|
||||
if (!wantBlocks) break;
|
||||
const text = textOfToolResult(data.message);
|
||||
if (text) blocks.push({ kind: 'tool', label: 'Tool', role: 'assistant', text });
|
||||
break;
|
||||
}
|
||||
case 'turn/end': {
|
||||
const turn = getTurn(turnNo);
|
||||
const reason = asRecord(data.reason);
|
||||
if (reason && reason.kind !== 'completed') {
|
||||
// Two different things wear this field: a provider failure
|
||||
// (`kind:'error'` with a message) and an ordinary early stop
|
||||
// (`kind:'max-tokens'`, measured live). Calling the second one an
|
||||
// error would misreport a truncated but real answer.
|
||||
const error = asRecord(reason.error);
|
||||
const message = typeof error?.message === 'string' ? error.message : undefined;
|
||||
const kind = typeof reason.kind === 'string' ? reason.kind : 'unknown';
|
||||
turn.ending = message ? `Turn error: ${message}` : `Turn ended: ${kind}`;
|
||||
if (wantBlocks) {
|
||||
blocks.push({ kind: 'status', label: 'Status', role: 'assistant', text: turn.ending });
|
||||
}
|
||||
}
|
||||
turn.timestamp = isoTime(event.time) || turn.timestamp;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const lastTurn = turnOrder.length > 0 ? turns.get(turnOrder[turnOrder.length - 1] as number) : undefined;
|
||||
let text = lastTurn ? turnText(lastTurn) : '';
|
||||
// A turn that failed and said nothing answers with its failure, labelled so
|
||||
// it can never read as the model's own words. Without this an agent polls
|
||||
// `last-response` fifteen times and concludes the worker never answered.
|
||||
if (!text && lastTurn?.ending) text = lastTurn.ending;
|
||||
|
||||
return { text, timestamp: lastTurn?.timestamp ?? '', blocks, sessionId, cwd };
|
||||
}
|
||||
|
||||
/**
|
||||
* `$DSH_HOME` for one session: a per-session override wins (`DSH_HOME` is an
|
||||
* allowlisted `envOverrides` prefix, and pointing a worker at its own profile
|
||||
* tree is a documented thing to do), then the server's own environment, then
|
||||
* `~/.dsh`. Reading the wrong tree does not fail loudly — it silently finds no
|
||||
* transcript — so this must resolve exactly the way the spawn did.
|
||||
*/
|
||||
/* ⚠️ The override is EPHEMERAL: `envOverrides` is applied at spawn and exported
|
||||
* through `tmux setenv`, but is deliberately not persisted to state.json (it can
|
||||
* carry provider keys). A session that overrode `DSH_HOME` and then outlived a
|
||||
* server restart therefore resolves to the default tree and finds no transcript
|
||||
* — it reads as "nothing said yet" rather than as another session's answer,
|
||||
* because every candidate is matched on its recorded `cwd`. */
|
||||
export function resolveDeepSeekHome(session: { deepSeekHomeOverride?: string }): string {
|
||||
const override = session.deepSeekHomeOverride;
|
||||
if (override && override.trim()) return override.trim();
|
||||
const fromEnv = process.env.DSH_HOME;
|
||||
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
||||
return join(homedir(), '.dsh');
|
||||
}
|
||||
|
||||
/**
|
||||
* How far apart a session's start and its transcript's `createdAt` may be and
|
||||
* still be the same session. dsh writes the header within ~2 s of pane start
|
||||
* (measured); 60 s absorbs a cold profile boot without ever reaching a sibling
|
||||
* started minutes later.
|
||||
*/
|
||||
const PAIRING_WINDOW_MS = 60_000;
|
||||
|
||||
/** Transcript file names dsh has used, newest convention first. */
|
||||
const TRANSCRIPT_FILES = ['session.jsonl.zstd', 'session.jsonl'];
|
||||
|
||||
/**
|
||||
* Locate the transcript for a session.
|
||||
*
|
||||
* dsh buckets sessions by a mangled cwd (`--home-you-code-app--`) and then by
|
||||
* its own session id, and the id form has changed between versions (`<uuid>`
|
||||
* and `session-<uuid>` both exist on disk here). ⚠️ So the mangling is NOT
|
||||
* reproduced: every candidate's own header line carries `cwd`, which is
|
||||
* authoritative, and matching on it is immune to the next naming change.
|
||||
*
|
||||
* Pairing a Codeman session with ITS transcript then has one hard rule and one
|
||||
* ladder. The rule: a transcript created BEFORE this session started belongs to
|
||||
* an earlier conversation in the same directory and is never eligible. Measured
|
||||
* cost of getting that wrong — a freshly spawned worker answered its very first
|
||||
* `last-response` with the PREVIOUS session's reply, which is worse than saying
|
||||
* nothing, because an agent cannot tell a stale answer from a fresh one.
|
||||
*
|
||||
* The ladder, once the older ones are out:
|
||||
*
|
||||
* 1. a transcript whose header `createdAt` sits within `PAIRING_WINDOW_MS` of
|
||||
* this session's start — that is this pane's own boot, and it stays right
|
||||
* even when a sibling session is running in the same case directory;
|
||||
* 2. otherwise the newest transcript created after this session started;
|
||||
* 3. otherwise nothing.
|
||||
*
|
||||
* ⚠️ The boot transcript wins for as long as it exists on disk — deliberately,
|
||||
* and even over a LATER transcript in the same workspace. Step 2 cannot tell a
|
||||
* `/new` from a sibling session that started later in the same directory, so
|
||||
* preferring newest-eligible would hand a worker its busier sibling's reply
|
||||
* (the exact bug the hard rule above was measured against, one seat over).
|
||||
* The cost of that choice: after an interactive `/new` in a dsh tab, this
|
||||
* reader keeps serving the pre-`/new` conversation (the same session's own
|
||||
* earlier turns — stale, never foreign); step 2 is reached only when no
|
||||
* boot-window transcript exists. Worker fleets never `/new`, so they only
|
||||
* ever see step 1.
|
||||
*/
|
||||
export async function findDeepSeekTranscript(options: {
|
||||
dshHome: string;
|
||||
workingDir: string;
|
||||
startedAt?: number;
|
||||
}): Promise<string | null> {
|
||||
const sessionsDir = join(options.dshHome, 'sessions');
|
||||
let buckets: string[];
|
||||
try {
|
||||
buckets = (await fs.readdir(sessionsDir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
||||
for (const bucket of buckets) {
|
||||
const bucketPath = join(sessionsDir, bucket);
|
||||
let sessions: string[];
|
||||
try {
|
||||
sessions = (await fs.readdir(bucketPath, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const sessionDir of sessions) {
|
||||
for (const file of TRANSCRIPT_FILES) {
|
||||
const path = join(bucketPath, sessionDir, file);
|
||||
const stat = await fs.stat(path).catch(() => null);
|
||||
if (!stat || !stat.isFile() || stat.size === 0) continue;
|
||||
candidates.push({ path, mtimeMs: stat.mtimeMs });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
const startedAt = options.startedAt ?? 0;
|
||||
// Slack in both directions: the harness writes its header a beat after the
|
||||
// pane starts, and mtimes on a shared clock are not worth trusting to the ms.
|
||||
const floor = startedAt > 0 ? startedAt - PAIRING_WINDOW_MS : 0;
|
||||
|
||||
let laterMatch: string | null = null;
|
||||
for (const candidate of candidates) {
|
||||
const header = await readTranscriptHeader(candidate.path);
|
||||
if (!header || header.cwd !== options.workingDir) continue;
|
||||
// No usable header timestamp: fall back to the file's own mtime, which is
|
||||
// still enough to keep a pre-session transcript out.
|
||||
const createdAt = header.createdAt ?? candidate.mtimeMs;
|
||||
if (createdAt < floor) continue;
|
||||
if (startedAt > 0 && Math.abs(createdAt - startedAt) <= PAIRING_WINDOW_MS) return candidate.path;
|
||||
if (!laterMatch) laterMatch = candidate.path;
|
||||
}
|
||||
return laterMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only the first frame of a transcript, which is where the header line
|
||||
* lives. Bounded: a candidate scan must never decompress every conversation on
|
||||
* the box to answer one `last-response` call.
|
||||
*/
|
||||
async function readTranscriptHeader(path: string): Promise<{ cwd?: string; id?: string; createdAt?: number } | null> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(path, 'r');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const head = Buffer.alloc(65536);
|
||||
const { bytesRead } = await handle.read(head, 0, head.length, 0);
|
||||
if (bytesRead === 0) return null;
|
||||
const text = decodeZstdFrames(head.subarray(0, bytesRead));
|
||||
const firstLine = text.split('\n').find((line) => line.trim());
|
||||
if (!firstLine) return null;
|
||||
const parsed = JSON.parse(firstLine) as { type?: string; cwd?: string; id?: string; createdAt?: number };
|
||||
if (parsed.type !== 'session') return null;
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
id: parsed.id,
|
||||
createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Hard ceiling on a transcript read. A long agent run is a few hundred KB; a
|
||||
* file past this is pathological and is not worth a synchronous decode. */
|
||||
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Memo of the last few decoded transcripts, keyed on (path, mtime, size,
|
||||
* blocks). The skill's `last_text` polls once per second, and each poll used
|
||||
* to zstdDecompressSync + reparse the WHOLE file on the event loop even when
|
||||
* nothing had been appended — a multi-MB transcript made that a repeated
|
||||
* ~100ms-class stall on the single-threaded server. A poll that finds the
|
||||
* file unchanged now costs one stat. Insertion-order eviction; tiny, because
|
||||
* an entry only earns its keep while a session is being actively polled.
|
||||
*/
|
||||
const parseMemo = new Map<string, DeepSeekTranscriptResult>();
|
||||
const PARSE_MEMO_MAX = 16;
|
||||
|
||||
/** Test seam: a fixture that rewrites one path in place inside a single mtime
|
||||
* tick would otherwise read its predecessor back out of the memo. */
|
||||
export function resetDeepSeekTranscriptMemoForTest(): void {
|
||||
parseMemo.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one dsh session's last answer.
|
||||
*
|
||||
* ⚠️ The two empty outcomes are deliberately different, because the caller must
|
||||
* treat them differently:
|
||||
*
|
||||
* - `null` means **this reader cannot run here** (a Node without zstd), and is
|
||||
* the signal to fall back to the pane segmenter.
|
||||
* - an empty `text` means **read fine, nothing said yet** — no transcript for
|
||||
* this workspace, or a turn still in flight.
|
||||
*
|
||||
* Collapsing the two would put the ASCII-art splash back in front of an agent
|
||||
* that is polling for a worker's first answer.
|
||||
*/
|
||||
export async function readDeepSeekLastResponse(
|
||||
session: { workingDir: string; createdAt?: Date | number; deepSeekHomeOverride?: string },
|
||||
options: { blocks?: boolean } = {}
|
||||
): Promise<DeepSeekTranscriptResult | null> {
|
||||
const createdAt = session.createdAt instanceof Date ? session.createdAt.getTime() : session.createdAt;
|
||||
// dsh compresses by default, so a Node without zstd can read nothing here.
|
||||
// That is the one case the pane is still the better answer.
|
||||
if (!zstdSupported()) return null;
|
||||
|
||||
const empty: DeepSeekTranscriptResult = { text: '', timestamp: '', blocks: [] };
|
||||
const path = await findDeepSeekTranscript({
|
||||
dshHome: resolveDeepSeekHome(session),
|
||||
workingDir: session.workingDir,
|
||||
startedAt: typeof createdAt === 'number' ? createdAt : undefined,
|
||||
});
|
||||
if (!path) return empty;
|
||||
|
||||
const stat = await fs.stat(path).catch(() => null);
|
||||
if (!stat || stat.size > MAX_TRANSCRIPT_BYTES) return empty;
|
||||
|
||||
const memoKey = `${path}|${stat.mtimeMs}|${stat.size}|${options.blocks ? 1 : 0}`;
|
||||
const memoized = parseMemo.get(memoKey);
|
||||
if (memoized) return memoized;
|
||||
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await fs.readFile(path);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
const result = parseDeepSeekTranscript(decodeZstdFrames(buf), options);
|
||||
if (parseMemo.size >= PARSE_MEMO_MAX) {
|
||||
const oldest = parseMemo.keys().next().value;
|
||||
if (oldest !== undefined) parseMemo.delete(oldest);
|
||||
}
|
||||
parseMemo.set(memoKey, result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @fileoverview Supervises the one background `dsh web` process behind the Run
|
||||
* menu's "DeepSeek web UI..." entry.
|
||||
*
|
||||
* The shortcut originally started the server inside an ordinary SHELL SESSION,
|
||||
* on the reasoning that Codeman already knows how to supervise those: it was
|
||||
* visible, scrollable, killable, and died with its tab, and nothing new had to
|
||||
* own a long-lived HTTP server. That reasoning was sound and the result was
|
||||
* still wrong in use — clicking "open the DeepSeek web UI" spawned a terminal
|
||||
* tab the user never asked for, next to the web tab they did, and the terminal
|
||||
* was noise every time after the first.
|
||||
*
|
||||
* So the server moves here instead: one child process, no session, no tab.
|
||||
* What that buys back has to be paid for explicitly, which is what this module
|
||||
* is:
|
||||
*
|
||||
* - **Exactly one.** A second click reuses the running server rather than
|
||||
* racing it for a port. The old shell-session flow could not do this at all,
|
||||
* because two clicks were simply two sessions.
|
||||
* - **Restarted when the authority changes.** `--trusted-host` fences dsh's
|
||||
* `/api` against the browser authority, and a Codeman reachable at both
|
||||
* loopback and a tailnet name has two. Whoever asks last wins, because the
|
||||
* asker is by definition the origin about to load the page.
|
||||
* - **Killed on shutdown.** A detached child that outlived Codeman would hold
|
||||
* its port against the next start, which is exactly the EADDRINUSE this
|
||||
* feature already got wrong once.
|
||||
* - **Failures reported, not swallowed.** The shell tab used to be where the
|
||||
* stack trace landed. With no tab, the spawn's own output is captured and
|
||||
* handed back to the caller instead.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { getErrorMessage } from './types.js';
|
||||
|
||||
/**
|
||||
* Where the port search starts, and how far it walks.
|
||||
*
|
||||
* 3080 is `dsh web`'s own default, so it is the friendly first choice — and
|
||||
* emphatically not a fixed port. DeepSeek's web UI is a thing users run
|
||||
* themselves, which makes the default precisely the port most likely to be
|
||||
* taken already; hardcoding it made this feature die with EADDRINUSE against
|
||||
* the user's own server.
|
||||
*/
|
||||
const PORT_BASE = 3080;
|
||||
const PORT_SPAN = 40;
|
||||
|
||||
/** How long a freshly spawned server gets to answer before we call it failed. */
|
||||
const READY_TIMEOUT_MS = 30_000;
|
||||
const READY_POLL_MS = 400;
|
||||
/** Grace between SIGTERM and SIGKILL when stopping the tree. */
|
||||
const KILL_GRACE_MS = 3_000;
|
||||
/** Bound on captured child output, so a chatty boot cannot grow without limit. */
|
||||
const OUTPUT_CAP = 16_384;
|
||||
|
||||
export interface DeepSeekWebStatus {
|
||||
running: boolean;
|
||||
port: number | null;
|
||||
url: string | null;
|
||||
/** Browser authority this server was started to trust (`--trusted-host`). */
|
||||
authority: string | null;
|
||||
}
|
||||
|
||||
interface RunningServer {
|
||||
child: ChildProcess;
|
||||
port: number;
|
||||
authority: string;
|
||||
output: () => string;
|
||||
}
|
||||
|
||||
let current: RunningServer | null = null;
|
||||
|
||||
/**
|
||||
* True when nothing holds `port` on loopback.
|
||||
*
|
||||
* Binding is the only honest test: a connect probe cannot tell "free" from
|
||||
* "listening but not answering yet", and this runs moments before `dsh web`
|
||||
* binds the same port. It is inherently racy, which is why the caller still
|
||||
* waits for the server to actually answer before reporting success.
|
||||
*/
|
||||
async function isLoopbackPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const probe = createServer();
|
||||
probe.once('error', () => resolve(false));
|
||||
probe.once('listening', () => probe.close(() => resolve(true)));
|
||||
probe.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
async function findFreePort(): Promise<number | null> {
|
||||
for (let port = PORT_BASE; port < PORT_BASE + PORT_SPAN; port++) {
|
||||
if (await isLoopbackPortFree(port)) return port;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does the server answer HTTP yet? Any status counts: dsh may 4xx a bare GET. */
|
||||
async function answersHttp(port: number): Promise<boolean> {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2_000) });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the whole process group.
|
||||
*
|
||||
* `dsh web` boots a plugin tree and fans out, so signalling only the direct
|
||||
* child leaves survivors holding the port. Same negative-pid escalation as
|
||||
* `runGit()` in git-clone.ts and the profile installer.
|
||||
*/
|
||||
function killTree(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeepSeekWebStatus(): DeepSeekWebStatus {
|
||||
if (!current) return { running: false, port: null, url: null, authority: null };
|
||||
return {
|
||||
running: true,
|
||||
port: current.port,
|
||||
url: `http://127.0.0.1:${current.port}`,
|
||||
authority: current.authority,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop the background server, if one is running. Safe to call when none is. */
|
||||
export async function stopDeepSeekWeb(): Promise<void> {
|
||||
const running = current;
|
||||
current = null;
|
||||
if (!running) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(hard);
|
||||
resolve();
|
||||
};
|
||||
running.child.once('exit', finish);
|
||||
killTree(running.child, 'SIGTERM');
|
||||
const hard = setTimeout(() => {
|
||||
killTree(running.child, 'SIGKILL');
|
||||
finish();
|
||||
}, KILL_GRACE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
type StartResult = { ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Serializes concurrent starts. Two POSTs racing (two devices, or a double
|
||||
* click while the first boots) used to both see `current === null`, pick the
|
||||
* SAME free port, and spawn twice: the loser died on EADDRINUSE while its exit
|
||||
* handler nulled the singleton out from under the winner, leaving a live
|
||||
* `dsh web` nothing tracked or killed — the exact orphan this module exists to
|
||||
* prevent. The second caller now simply waits and reuses the first's server.
|
||||
*/
|
||||
let startLock: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Start (or reuse) the background `dsh web` for `authority`.
|
||||
*
|
||||
* @param dshDir directory holding the resolved `dsh` binary.
|
||||
* @param authority browser authority to pass as `--trusted-host`.
|
||||
*/
|
||||
export function startDeepSeekWeb(dshDir: string, authority: string): Promise<StartResult> {
|
||||
const run = startLock.then(
|
||||
() => startDeepSeekWebLocked(dshDir, authority),
|
||||
() => startDeepSeekWebLocked(dshDir, authority)
|
||||
);
|
||||
startLock = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function startDeepSeekWebLocked(dshDir: string, authority: string): Promise<StartResult> {
|
||||
// Reuse only when the running server is BOTH healthy and fenced for the
|
||||
// authority now asking. A server trusting the other origin renders a page
|
||||
// whose every API call 403s, which looks like a broken dashboard rather than
|
||||
// a misconfigured one.
|
||||
if (current) {
|
||||
if (current.authority === authority && (await answersHttp(current.port))) {
|
||||
return { ok: true, port: current.port, url: `http://127.0.0.1:${current.port}`, reused: true };
|
||||
}
|
||||
await stopDeepSeekWeb();
|
||||
}
|
||||
|
||||
const port = await findFreePort();
|
||||
if (port === null) {
|
||||
return { ok: false, error: `No free port for the DeepSeek web UI in ${PORT_BASE}-${PORT_BASE + PORT_SPAN - 1}` };
|
||||
}
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(
|
||||
join(dshDir, 'dsh'),
|
||||
['web', '--no-open', '--host', '127.0.0.1', '--port', String(port), '--trusted-host', authority],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group so the whole plugin tree can be signalled at once.
|
||||
detached: true,
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Failed to start dsh web: ${getErrorMessage(err)}` };
|
||||
}
|
||||
|
||||
// The pipes must be drained whether or not anyone reads them: a full pipe
|
||||
// blocks the child. Storage is capped; draining is not.
|
||||
let output = '';
|
||||
const capture = (chunk: Buffer) => {
|
||||
if (output.length < OUTPUT_CAP) output += chunk.toString('utf-8');
|
||||
};
|
||||
child.stdout?.on('data', capture);
|
||||
child.stderr?.on('data', capture);
|
||||
|
||||
let exited = false;
|
||||
child.once('exit', () => {
|
||||
exited = true;
|
||||
// Only clear if this is still the current server: a restart may have
|
||||
// already replaced it, and clearing then would drop the live one.
|
||||
if (current?.child === child) current = null;
|
||||
});
|
||||
child.once('error', () => {
|
||||
exited = true;
|
||||
if (current?.child === child) current = null;
|
||||
});
|
||||
|
||||
const running: RunningServer = { child, port, authority, output: () => output };
|
||||
current = running;
|
||||
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) {
|
||||
// Guarded like the exit/error handlers: a concurrent stop (DELETE route,
|
||||
// shutdown) may already have cleared or replaced the singleton, and an
|
||||
// unconditional null here would drop a server this call does not own.
|
||||
if (current === running) current = null;
|
||||
const tail = output.trim().slice(-800);
|
||||
return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' };
|
||||
}
|
||||
if (await answersHttp(port)) {
|
||||
return { ok: true, port, url: `http://127.0.0.1:${port}`, reused: false };
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
||||
}
|
||||
|
||||
// Timeout: kill OUR child. Only route through stopDeepSeekWeb() while the
|
||||
// singleton is still ours — signalling `current` unconditionally here could
|
||||
// SIGTERM a healthy server a concurrent actor now owns.
|
||||
if (current === running) {
|
||||
await stopDeepSeekWeb();
|
||||
} else {
|
||||
killTree(running.child, 'SIGKILL');
|
||||
}
|
||||
const tail = output.trim().slice(-800);
|
||||
return {
|
||||
ok: false,
|
||||
error: tail
|
||||
? `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s: ${tail}`
|
||||
: `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: forget any tracked child without signalling it. */
|
||||
export function resetDeepSeekWebForTest(): void {
|
||||
current = null;
|
||||
}
|
||||
@@ -145,6 +145,9 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
gemini: 'exec gemini',
|
||||
antigravity: 'exec agy',
|
||||
pi: 'exec pi',
|
||||
grok: 'exec grok',
|
||||
deepseek: 'exec dsh',
|
||||
omp: 'exec omp',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
@@ -614,8 +617,45 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
rel: '.pi/agent',
|
||||
seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'],
|
||||
},
|
||||
// Grok (xAI) keeps auth + config in `~/.grok`, but that dir ALSO holds
|
||||
// `sessions/`, `memory/`, `downloads/` (the ~100MB binary itself) and `bin/`,
|
||||
// so seedWhole would copy all of it into every container start. Seed only what
|
||||
// grok needs to authenticate and behave consistently. Same trade-off as pi:
|
||||
// in-container grok sessions are invisible host-side, so `grok -c` inside a
|
||||
// Docker case only sees that container's own history.
|
||||
{
|
||||
rel: '.grok',
|
||||
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
|
||||
},
|
||||
// DeepSeek Harness keeps credentials in `~/.dsh/.env` (0600) and composition in
|
||||
// `settings.yaml` / `cordis.patch.yml`. `profiles/` is deliberately NOT seeded:
|
||||
// it is a pnpm workspace holding a full node_modules tree per profile, which is
|
||||
// both enormous and host-arch-specific. An in-container dsh therefore needs its
|
||||
// profile installed IN the image (see docker/agent.Dockerfile), and the seeded
|
||||
// files only supply auth and model composition. Same host-invisibility trade-off
|
||||
// as pi and grok: `~/.dsh/sessions` inside a container is that container's own.
|
||||
{
|
||||
rel: '.dsh',
|
||||
seedFiles: ['.env', 'settings.yaml', 'cordis.patch.yml'],
|
||||
},
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
|
||||
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
|
||||
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
|
||||
// terminal-sessions/blobs/cache (large, regenerable), so seed only the
|
||||
// config files. UNLIKE pi/grok, `sessions/` is SHARED (RW), not
|
||||
// host-invisible: Codeman reads `~/.omp/agent/sessions/**/*.jsonl`
|
||||
// HOST-SIDE for history recovery and --resume pinning
|
||||
// (omp-transcript.ts, omp-session-resolver.ts) — the same reason codex's
|
||||
// `sessions/` is shared rather than seeded. Without this, an in-container
|
||||
// OMP conversation would be invisible to Codeman's own history-scan/resume
|
||||
// logic, silently breaking the kill-survival feature for Docker cases.
|
||||
{
|
||||
rel: '.omp/agent',
|
||||
shareDirs: ['sessions'],
|
||||
seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+12
-3
@@ -19,6 +19,9 @@ import type {
|
||||
GeminiConfig,
|
||||
AntigravityConfig,
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
DeepSeekConfig,
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -78,13 +81,16 @@ export interface CreateSessionOptions {
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session. */
|
||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
@@ -110,13 +116,16 @@ export interface RespawnPaneOptions {
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
@@ -216,7 +225,7 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Update Ralph enabled state for a session */
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||
|
||||
/** Apply a tmux history-limit to all tracked sessions. */
|
||||
/** Apply history-limit to live panes where tmux supports it, otherwise to future panes. */
|
||||
setHistoryLimit(limit: number): Promise<void>;
|
||||
|
||||
// ========== Discovery ==========
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview Scan `~/.omp/agent/sessions/*/*.jsonl` for Past Sessions rows,
|
||||
* the omp analog of what `scanProjectDir()` (session-routes.ts) does for
|
||||
* Claude's own `~/.claude/projects` transcripts.
|
||||
*
|
||||
* Without this, an omp conversation exists ONLY as a Codeman-level live/
|
||||
* persisted session record — delete that (a "Kill Tmux" close, or any other
|
||||
* cleanup) and the conversation vanishes from Past Sessions entirely, even
|
||||
* though `omp` itself never forgot it. Claude conversations don't have that
|
||||
* problem because Codeman already reads them back from Claude's own
|
||||
* transcript files independent of its own session bookkeeping; this gives
|
||||
* omp conversations the same treatment.
|
||||
*
|
||||
* Each omp session file's SECOND line is a `{"type":"session","id":...,
|
||||
* "cwd":...}` header carrying the real (unmangled) working directory and the
|
||||
* session's own id directly — no need to reverse-engineer the mangled
|
||||
* directory name the way Claude Code's own scanner has to (see
|
||||
* `decodeProjectKey()` in session-routes.ts and its "lossy" caveat). Prompt
|
||||
* text comes from each `{"type":"message","message":{"role":"user",...}}`
|
||||
* entry, giving a real first-message title instead of a bare case name.
|
||||
*
|
||||
* Unlike Claude's transcripts (which can run to tens of MB of tool-call
|
||||
* output), an omp session file is the conversation only, so this reads each
|
||||
* file whole rather than doing head/tail windows — bounded by a size cap so
|
||||
* one unexpectedly huge file can't blow up memory.
|
||||
*
|
||||
* @module omp-transcript
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function ompSessionsRoot(): string {
|
||||
return join(homedir(), '.omp', 'agent', 'sessions');
|
||||
}
|
||||
|
||||
/** Skip anything absurdly large rather than parsing it whole into memory. */
|
||||
const MAX_OMP_SESSION_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Defensive cap on total files scanned across every directory, mirroring
|
||||
* the Claude scanner's own instinct not to let one pathological tree stall
|
||||
* a request — a real omp install has, at most, a few hundred of these. */
|
||||
const MAX_OMP_SESSION_FILES = 2000;
|
||||
|
||||
export interface OmpHistorySession {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
/** ISO timestamp, from the file's own mtime. */
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
}
|
||||
|
||||
function extractUserPromptText(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== 'object') return undefined;
|
||||
const m = message as { role?: unknown; content?: unknown };
|
||||
if (m.role !== 'user' || !Array.isArray(m.content)) return undefined;
|
||||
const parts: string[] = [];
|
||||
for (const block of m.content) {
|
||||
if (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text') {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === 'string') parts.push(text);
|
||||
}
|
||||
}
|
||||
const joined = parts.join(' ').trim();
|
||||
return joined || undefined;
|
||||
}
|
||||
|
||||
/** Parse one omp session `.jsonl` file, or null when it's unreadable, empty, or has no session header. */
|
||||
function parseOmpSessionFile(filePath: string): OmpHistorySession | null {
|
||||
let stat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
stat = statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (stat.size === 0 || stat.size > MAX_OMP_SESSION_FILE_BYTES) return null;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let workingDir: string | undefined;
|
||||
let firstPrompt: string | undefined;
|
||||
let lastPrompt: string | undefined;
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string' && e.cwd.startsWith('/')) {
|
||||
// A corrupted or malformed session file could carry a relative or empty
|
||||
// cwd; requiring an absolute path keeps a downstream resume attempt
|
||||
// from being pointed at a nonsense working directory.
|
||||
sessionId = e.id;
|
||||
workingDir = e.cwd;
|
||||
} else if (e.type === 'message') {
|
||||
const prompt = extractUserPromptText(e.message);
|
||||
if (prompt) {
|
||||
if (!firstPrompt) firstPrompt = prompt;
|
||||
lastPrompt = prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId || !workingDir) return null;
|
||||
return {
|
||||
sessionId,
|
||||
workingDir,
|
||||
sizeBytes: stat.size,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
lastPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every omp conversation on disk into Past-Sessions rows. Best-effort
|
||||
* throughout: a missing `~/.omp` (never installed/used), an unreadable
|
||||
* directory, or one corrupt file yields fewer rows rather than throwing —
|
||||
* this feeds the same unified merge the Claude transcript scanner does, and
|
||||
* one broken source must never blank the whole Past Sessions list.
|
||||
*/
|
||||
export function scanOmpSessionsHistory(): OmpHistorySession[] {
|
||||
const root = ompSessionsRoot();
|
||||
let dirEntries: string[];
|
||||
try {
|
||||
dirEntries = readdirSync(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: OmpHistorySession[] = [];
|
||||
for (const dirName of dirEntries) {
|
||||
if (out.length >= MAX_OMP_SESSION_FILES) break;
|
||||
const dirPath = join(root, dirName);
|
||||
let dirStat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
dirStat = statSync(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!dirStat.isDirectory()) continue;
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (out.length >= MAX_OMP_SESSION_FILES) break;
|
||||
if (!file.endsWith('.jsonl')) continue;
|
||||
try {
|
||||
const parsed = parseOmpSessionFile(join(dirPath, file));
|
||||
if (parsed) out.push(parsed);
|
||||
} catch {
|
||||
// One bad file must not sink the whole scan.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -114,6 +114,12 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
gemini: remoteLoginShellCommand('gemini'),
|
||||
antigravity: remoteLoginShellCommand('agy'),
|
||||
pi: remoteLoginShellCommand('pi'),
|
||||
grok: remoteLoginShellCommand('grok'),
|
||||
// `dsh` alone boots nothing: the launcher needs a profile, and the remote box's
|
||||
// profile inventory is unknown here. The per-host `commands.deepseek` override
|
||||
// is the escape hatch for naming one.
|
||||
deepseek: remoteLoginShellCommand('dsh'),
|
||||
omp: remoteLoginShellCommand('omp'),
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
@@ -269,6 +275,7 @@ const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
|
||||
gemini: 'gemini',
|
||||
antigravity: 'agy',
|
||||
pi: 'pi',
|
||||
omp: 'omp',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,6 +99,13 @@ export type HistoryInput = {
|
||||
gitBranch?: string;
|
||||
worktreeName?: string;
|
||||
worktreeRepo?: string;
|
||||
/**
|
||||
* Set only by a non-claude transcript source (currently omp); the Claude
|
||||
* scanner never stamps this; the meaningfulness floor below still counts a
|
||||
* row with a `mode` as real, since that also signals "not claude" — see
|
||||
* where it's read below for the isReal check this touches.
|
||||
*/
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
@@ -175,6 +182,10 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'gitBranch', h.gitBranch);
|
||||
overwrite(item, 'worktreeName', h.worktreeName);
|
||||
overwrite(item, 'worktreeRepo', h.worktreeRepo);
|
||||
// Claude rows never set this (they're implicitly claude); a non-claude
|
||||
// transcript source (currently only omp) does, so a history-only row
|
||||
// still gets a mode badge instead of reading as claude by default.
|
||||
overwrite(item, 'mode', h.mode);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
|
||||
+208
-11
@@ -51,9 +51,13 @@ import {
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import { probeRemoteCliVersion } from './remote-hosts.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
@@ -171,7 +175,16 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi';
|
||||
return (
|
||||
mode === 'opencode' ||
|
||||
mode === 'codex' ||
|
||||
mode === 'gemini' ||
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek' ||
|
||||
mode === 'omp'
|
||||
);
|
||||
}
|
||||
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
@@ -186,6 +199,12 @@ function getModeLabel(mode: SessionMode): string {
|
||||
return 'Antigravity';
|
||||
case 'pi':
|
||||
return 'Pi';
|
||||
case 'grok':
|
||||
return 'Grok';
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
case 'omp':
|
||||
return 'OMP';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
@@ -202,8 +221,9 @@ function getModeLabel(mode: SessionMode): string {
|
||||
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
|
||||
* TUI that may rely on it) and `pi` (below). Keep parity with the replay-side
|
||||
* strip in session-routes.ts.
|
||||
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
|
||||
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
|
||||
* with the replay-side strip in session-routes.ts.
|
||||
*
|
||||
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
|
||||
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
||||
@@ -508,6 +528,13 @@ export class Session extends EventEmitter {
|
||||
private _antigravityConfig: AntigravityConfig | undefined;
|
||||
// Pi configuration (only for mode === 'pi')
|
||||
private _piConfig: PiConfig | undefined;
|
||||
// Grok configuration (only for mode === 'grok')
|
||||
private _grokConfig: GrokConfig | undefined;
|
||||
|
||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||
// OMP configuration (only for mode === 'omp')
|
||||
private _ompConfig: OmpConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -519,7 +546,7 @@ export class Session extends EventEmitter {
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||
// tmux history-limit (scrollback lines) allocated when this session's pane is created.
|
||||
private readonly _tmuxHistoryLimit: number;
|
||||
|
||||
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||
@@ -603,13 +630,19 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Pi configuration (only for mode === 'pi') */
|
||||
piConfig?: PiConfig;
|
||||
/** Grok configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP configuration (only for mode === 'omp') */
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) for this session's pane. */
|
||||
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
|
||||
tmuxHistoryLimit?: number;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
@@ -658,7 +691,13 @@ export class Session extends EventEmitter {
|
||||
this._wireActivityAt = config.lastActivityAt || Date.now();
|
||||
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||
// For omp, `claudeSessionId` doubles as the generic "external transcript id"
|
||||
// alias key mergeUnifiedSessions() folds a history row into its owning
|
||||
// session by: omp mints its OWN uuid, unrelated to this Codeman id, so
|
||||
// without this an omp conversation's Past-Sessions row (keyed by omp's
|
||||
// id) would never merge with its own live/persisted row (keyed by this
|
||||
// id) — it would just show up a second time.
|
||||
this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
// to the launch id even when re-attaching to a mux session whose CLI has
|
||||
// moved on (a `/clear` before the restart), so this anchor is what lets the
|
||||
@@ -711,6 +750,20 @@ export class Session extends EventEmitter {
|
||||
if (config.piConfig) {
|
||||
this._piConfig = config.piConfig;
|
||||
}
|
||||
// Apply OMP configuration
|
||||
if (config.ompConfig) {
|
||||
this._ompConfig = config.ompConfig;
|
||||
}
|
||||
|
||||
// Apply DeepSeek Harness configuration
|
||||
if (config.deepSeekConfig) {
|
||||
this._deepSeekConfig = config.deepSeekConfig;
|
||||
}
|
||||
|
||||
// Apply Grok configuration
|
||||
if (config.grokConfig) {
|
||||
this._grokConfig = config.grokConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
@@ -859,6 +912,34 @@ export class Session extends EventEmitter {
|
||||
return this._remote;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent
|
||||
* none (i.e. ON), `false` when the user disarmed the status bridge for this
|
||||
* session.
|
||||
*
|
||||
* Exposed because whether a dsh session can deliver `stop`/`blocked` is a
|
||||
* per-SESSION fact, not a per-mode one, and `hooksAvailableForMode()` is pure
|
||||
* and holds no `Session` reference by design. Undefined for every other mode,
|
||||
* where the flag is meaningless.
|
||||
*/
|
||||
get deepSeekStatusReporting(): boolean | undefined {
|
||||
return this._deepSeekConfig?.statusReporting;
|
||||
}
|
||||
|
||||
/**
|
||||
* This session's `DSH_HOME` override, if it set one.
|
||||
*
|
||||
* Deliberately ONE key rather than an `envOverrides` getter: the map can hold
|
||||
* provider credentials (`DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, …) and is
|
||||
* kept off the public `SessionState` for exactly that reason. The transcript
|
||||
* reader needs the profile tree's location and nothing else, so that is all
|
||||
* this exposes.
|
||||
*/
|
||||
get deepSeekHomeOverride(): string | undefined {
|
||||
const value = this._envOverrides?.DSH_HOME;
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
@@ -1304,6 +1385,9 @@ export class Session extends EventEmitter {
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
@@ -1430,7 +1514,11 @@ export class Session extends EventEmitter {
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await mux.respawnPane(options.respawnPaneOptions);
|
||||
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
||||
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
||||
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
@@ -1476,7 +1564,12 @@ export class Session extends EventEmitter {
|
||||
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(
|
||||
this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity' || this.mode === 'pi'
|
||||
this.mode === 'codex' ||
|
||||
this.mode === 'gemini' ||
|
||||
this.mode === 'antigravity' ||
|
||||
this.mode === 'pi' ||
|
||||
this.mode === 'grok' ||
|
||||
this.mode === 'deepseek'
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -1516,6 +1609,9 @@ export class Session extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Confirmed the mux session (and thus the pane) exists but this reattach
|
||||
// is about to respawn it — safe to resolve/pin now.
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
@@ -1546,6 +1642,18 @@ export class Session extends EventEmitter {
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
// OMP resolution/pinning does NOT happen here. This object is built
|
||||
// EAGERLY — including on every boot-recovery reattach, before anyone
|
||||
// knows whether the pane is actually dead — so resolving here mutated
|
||||
// `_ompConfig`/`_claudeSessionId` even for a pane that was simply being
|
||||
// reattached to, not respawned; with two omp tabs in the same case dir
|
||||
// that mis-pinned the ALIVE session onto whichever file happened to be
|
||||
// newest on disk (reported live in the Ark0N/Codeman#353 review). The
|
||||
// real pin now happens in `_pinOmpRespawnId()`, called by callers ONLY
|
||||
// once they've confirmed an actual respawn is about to happen.
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1556,6 +1664,45 @@ export class Session extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OMP-only: resolve and PIN the exact conversation to continue when
|
||||
* respawning a dead pane, so every later respawn reuses the same id
|
||||
* instead of re-resolving (and re-risking picking up a DIFFERENT
|
||||
* conversation that happened to touch this directory more recently). See
|
||||
* the comment at the call site in {@link _buildRespawnPaneOptions} for why
|
||||
* "newest file on disk" is safe here specifically. Non-omp modes and a
|
||||
* session that already carries an explicit id pass through untouched.
|
||||
*/
|
||||
private _pinOmpRespawnId(): void {
|
||||
if (this.mode !== 'omp') return;
|
||||
if (this._ompConfig?.resumeSessionId) return;
|
||||
// Callers MUST call this only immediately before an ACTUAL respawn (a
|
||||
// confirmed-dead pane, or a genuine remote reattach) — never while merely
|
||||
// building options that might not lead to a respawn. A fresh "Run OMP"
|
||||
// click has no _muxSession yet and must never inherit whatever omp
|
||||
// conversation happens to be newest on disk for this working directory
|
||||
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
|
||||
// fix intact now that resolution has moved out of the eager options build.
|
||||
if (!this._muxSession) return;
|
||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||
if (resolvedId) {
|
||||
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
|
||||
// Alias omp's own session uuid to this Codeman id — see the
|
||||
// constructor's claudeSessionId comment for why this field is the
|
||||
// (generically-named) mechanism that folds a Past-Sessions row back
|
||||
// into its live/persisted session instead of duplicating it.
|
||||
this._claudeSessionId = resolvedId;
|
||||
return;
|
||||
}
|
||||
// Nothing unclaimed on disk (the dying process never got far enough to
|
||||
// write a session file, or a sibling already claimed the only candidate)
|
||||
// — fall back to the CLI's own "most recent" heuristic.
|
||||
console.warn(
|
||||
`[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue`
|
||||
);
|
||||
this._ompConfig = { ...this._ompConfig, continueSession: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember whether the CLI currently wants to be told about mouse clicks.
|
||||
*
|
||||
@@ -1804,6 +1951,9 @@ export class Session extends EventEmitter {
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1815,8 +1965,14 @@ export class Session extends EventEmitter {
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the
|
||||
// resumed one. `_pinOmpRespawnId()` (called just above, inside
|
||||
// `_setupOrAttachMuxSession()`'s dead-pane branch) may have JUST aliased
|
||||
// this to omp's own session uuid — that already-resolved id must win
|
||||
// over the generic `this.id` fallback, or this line clobbers it back
|
||||
// to the Codeman id
|
||||
// on every single respawn.
|
||||
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
@@ -1893,6 +2049,16 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'pi') {
|
||||
throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv
|
||||
if (this.mode === 'grok') {
|
||||
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
|
||||
// injection via setenv — and for the HERDR_* status-bridge triple, without
|
||||
// which the mode silently loses its definitive idle/blocked signals.
|
||||
if (this.mode === 'deepseek') {
|
||||
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
@@ -1924,7 +2090,12 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
// Mirrors the mux branch above and must not clobber it: this line runs
|
||||
// unconditionally after both the mux and direct-PTY paths, so it also needs
|
||||
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
|
||||
// OMP alias back to this.id on every mux/plain-reattach boot recovery
|
||||
// (the "third reset point" — see DECISIONS.md).
|
||||
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -2215,10 +2386,36 @@ export class Session extends EventEmitter {
|
||||
this._isWorking = false;
|
||||
this._status = 'idle';
|
||||
this._lastPromptTime = Date.now();
|
||||
if (wasWorking) this._maybeCaptureOmpSessionId();
|
||||
this.emit('idle');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A brand-new omp session (never yet respawned, so
|
||||
* {@link _pinOmpRespawnId} has never run) has no captured
|
||||
* omp-native session id: `_claudeSessionId` still defaults to this
|
||||
* session's OWN Codeman id from the constructor. Until something aliases
|
||||
* it, the omp history scan's row for this exact conversation (keyed by
|
||||
* omp's own uuid) merges with nothing and shows up a second time. The
|
||||
* first turn going idle is the first moment omp has definitely written
|
||||
* its session file, so resolve and alias it here — best-effort, and only
|
||||
* once (skips once `_claudeSessionId` differs from `this.id`, whether from
|
||||
* this capture or a resume/respawn that already resolved one).
|
||||
*/
|
||||
private _maybeCaptureOmpSessionId(): void {
|
||||
if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return;
|
||||
try {
|
||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||
if (resolvedId) {
|
||||
this._claudeSessionId = resolvedId;
|
||||
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: a failed capture just means the next respawn tries again.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
||||
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
||||
|
||||
+60
-14
@@ -40,6 +40,8 @@ import {
|
||||
} from './types.js';
|
||||
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import { dataPath, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import { normalizeSessionOrder } from './session-order.js';
|
||||
import { validateTabLayout, type TabLayout } from './tab-layout.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
@@ -281,6 +283,9 @@ export class StateStore {
|
||||
if (this.state.sessionOrder) {
|
||||
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
|
||||
}
|
||||
if (this.state.tabLayouts !== undefined) {
|
||||
parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -514,22 +519,28 @@ export class StateStore {
|
||||
*/
|
||||
cleanupStaleSessions(activeSessionIds: Set<string>): {
|
||||
count: number;
|
||||
cleaned: Array<{ id: string; name?: string }>;
|
||||
cleaned: Array<{ id: string; name?: string; owner?: string }>;
|
||||
} {
|
||||
const allSessionIds = Object.keys(this.state.sessions);
|
||||
const cleaned: Array<{ id: string; name?: string }> = [];
|
||||
const staleIds = new Set(Object.keys(this.state.sessions).filter((sessionId) => !activeSessionIds.has(sessionId)));
|
||||
return this.cleanupSessionsByIds(staleIds);
|
||||
}
|
||||
|
||||
for (const sessionId of allSessionIds) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
this.cachedSessionJsons.delete(sessionId);
|
||||
this.dirtySessions.delete(sessionId);
|
||||
// Also clean up Ralph state for this session
|
||||
this.ralphStates.delete(sessionId);
|
||||
}
|
||||
/** Deletes only confirmed stale session IDs, retaining records pinned after confirmation. */
|
||||
cleanupSessionsByIds(sessionIds: ReadonlySet<string>): {
|
||||
count: number;
|
||||
cleaned: Array<{ id: string; name?: string; owner?: string }>;
|
||||
} {
|
||||
const cleaned: Array<{ id: string; name?: string; owner?: string }> = [];
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const session = this.state.sessions[sessionId];
|
||||
if (!session || session.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||
cleaned.push({ id: sessionId, name: session.name, owner: session.owner });
|
||||
delete this.state.sessions[sessionId];
|
||||
this.cachedSessionJsons.delete(sessionId);
|
||||
this.dirtySessions.delete(sessionId);
|
||||
// Also clean up Ralph state for this session
|
||||
this.ralphStates.delete(sessionId);
|
||||
}
|
||||
|
||||
if (cleaned.length > 0) {
|
||||
@@ -664,6 +675,41 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns an owner layout, or null before that owner has been migrated. */
|
||||
getTabLayout(owner: string): TabLayout | null {
|
||||
const layouts = this.state.tabLayouts;
|
||||
return layouts && Object.hasOwn(layouts, owner) ? layouts[owner] : null;
|
||||
}
|
||||
|
||||
/** Returns a defensive snapshot of every stored owner layout. */
|
||||
getTabLayouts(): Record<string, TabLayout> {
|
||||
return structuredClone(this.state.tabLayouts ?? {});
|
||||
}
|
||||
|
||||
/** Validates and atomically persists one owner layout. */
|
||||
setTabLayout(owner: string, layout: TabLayout): void {
|
||||
const validated = validateTabLayout(layout);
|
||||
this.state.tabLayouts = { ...(this.state.tabLayouts ?? {}), [owner]: validated };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Atomically publishes validated owner layouts and their latest global compatibility projection. */
|
||||
commitTabLayoutProjection(
|
||||
layouts: Readonly<Record<string, TabLayout>>,
|
||||
projectOrder: (latest: readonly string[]) => readonly string[]
|
||||
): { layouts: Record<string, TabLayout>; sessionOrder: string[] } {
|
||||
const validated = Object.fromEntries(
|
||||
Object.entries(layouts).map(([owner, layout]) => [owner, validateTabLayout(layout)])
|
||||
);
|
||||
const sessionOrder = normalizeSessionOrder(projectOrder([...(this.state.sessionOrder ?? [])]));
|
||||
const nextLayouts = { ...(this.state.tabLayouts ?? {}), ...validated };
|
||||
|
||||
this.state.tabLayouts = nextLayouts;
|
||||
this.state.sessionOrder = sessionOrder;
|
||||
this.save();
|
||||
return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] };
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Pure compatibility translation between legacy session order and owner tab layouts.
|
||||
*/
|
||||
|
||||
import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js';
|
||||
import {
|
||||
normalizeTabLayout,
|
||||
validateTabLayout,
|
||||
type TabLayout,
|
||||
type TabRef,
|
||||
type TabRefMetadata,
|
||||
} from './tab-layout.js';
|
||||
|
||||
export interface OwnerOrderProjection {
|
||||
owner: string;
|
||||
ownedIds: readonly string[];
|
||||
order: readonly string[];
|
||||
}
|
||||
|
||||
export function applyLegacySessionRank(
|
||||
input: TabLayout,
|
||||
requestedOrder: readonly string[],
|
||||
metadata: readonly TabRefMetadata[]
|
||||
): TabLayout {
|
||||
const layout = validateTabLayout(input);
|
||||
const requestedRank = new Map(normalizeSessionOrder(requestedOrder).map((id, index) => [id, index]));
|
||||
const sessionMetadata = new Map<string, TabRefMetadata>();
|
||||
for (const item of metadata) {
|
||||
if (item.kind !== 'session' || !item.ownerValid || !item.visible || sessionMetadata.has(item.id)) continue;
|
||||
sessionMetadata.set(item.id, item);
|
||||
}
|
||||
|
||||
const isRanked = (ref: TabRef): boolean =>
|
||||
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
|
||||
const prepare = (ref: TabRef): TabRef => {
|
||||
if (ref.kind !== 'session') return { ...ref };
|
||||
const item = sessionMetadata.get(ref.id);
|
||||
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
|
||||
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
|
||||
};
|
||||
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
|
||||
const ranked = refs
|
||||
.filter(isRanked)
|
||||
.map(prepare)
|
||||
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
||||
let rankedIndex = 0;
|
||||
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
|
||||
};
|
||||
|
||||
const transformed: TabLayout = {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
|
||||
ungrouped: rankContainer(layout.ungrouped),
|
||||
};
|
||||
return normalizeTabLayout(transformed, metadata);
|
||||
}
|
||||
|
||||
export function recomposeGlobalSessionOrder(
|
||||
current: readonly string[],
|
||||
projections: readonly OwnerOrderProjection[],
|
||||
preferred?: readonly string[]
|
||||
): string[] {
|
||||
let result = mergeSessionOrder([...(preferred ?? current)], [...current]);
|
||||
for (const projection of projections) {
|
||||
const ownedIds = normalizeSessionOrder(projection.ownedIds);
|
||||
const owned = new Set(ownedIds);
|
||||
const canonical = normalizeSessionOrder(projection.order).filter((id) => owned.has(id));
|
||||
const canonicalSet = new Set(canonical);
|
||||
for (const id of ownedIds) {
|
||||
if (canonicalSet.has(id)) continue;
|
||||
canonicalSet.add(id);
|
||||
canonical.push(id);
|
||||
}
|
||||
|
||||
let canonicalIndex = 0;
|
||||
const recomposed = result.map((id) => (owned.has(id) ? canonical[canonicalIndex++] : id));
|
||||
recomposed.push(...canonical.slice(canonicalIndex));
|
||||
result = normalizeSessionOrder(recomposed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview Owner-scoped tab-layout persistence and legacy migration primitives.
|
||||
*
|
||||
* This module is deliberately independent of routes and runtime managers. Callers
|
||||
* provide persisted/live session facts plus saved webviews in server-store order.
|
||||
*/
|
||||
|
||||
import { normalizeTabLayout, type TabLayout, type TabRef, type TabRefMetadata } from './tab-layout.js';
|
||||
|
||||
export const SINGLE_USER_LAYOUT_OWNER = '@single';
|
||||
|
||||
export interface TabLayoutSessionRecord {
|
||||
id: string;
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
export interface TabLayoutWebviewRecord {
|
||||
id: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface TabLayoutMigrationInput {
|
||||
owner: string;
|
||||
layouts?: Readonly<Record<string, TabLayout>>;
|
||||
sessionOrder?: readonly string[];
|
||||
persistedSessions: readonly TabLayoutSessionRecord[];
|
||||
liveSessions: readonly TabLayoutSessionRecord[];
|
||||
/** Saved webviews in authoritative server-store order. */
|
||||
webviews: readonly TabLayoutWebviewRecord[];
|
||||
/** Required only when creating a layout, making migration deterministic in tests. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TabLayoutMigrationResult {
|
||||
layout: TabLayout;
|
||||
layouts: Record<string, TabLayout>;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
/** Resolve the persistence key without accepting an owner key from a client. */
|
||||
export function ownerLayoutKey(username?: string): string {
|
||||
return username || SINGLE_USER_LAYOUT_OWNER;
|
||||
}
|
||||
|
||||
function recordOwner(record: { owner?: string }): string {
|
||||
return record.owner ?? SINGLE_USER_LAYOUT_OWNER;
|
||||
}
|
||||
|
||||
function compareSessions(a: TabLayoutSessionRecord, b: TabLayoutSessionRecord): number {
|
||||
return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
}
|
||||
|
||||
function collectSessions(input: TabLayoutMigrationInput): Map<string, TabLayoutSessionRecord> {
|
||||
const sessions = new Map<string, TabLayoutSessionRecord>();
|
||||
for (const record of input.persistedSessions) sessions.set(record.id, { ...record });
|
||||
// A matching live record is authoritative as a whole. In particular, absent
|
||||
// optional owner/parent fields mean single-user ownership and root lineage;
|
||||
// retaining those fields from a stale persisted copy changes their semantics.
|
||||
for (const record of input.liveSessions) sessions.set(record.id, { ...record });
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function buildMetadata(
|
||||
input: TabLayoutMigrationInput,
|
||||
sessions: ReadonlyMap<string, TabLayoutSessionRecord>
|
||||
): TabRefMetadata[] {
|
||||
const ownerSessions = [...sessions.values()]
|
||||
.filter((record) => recordOwner(record) === input.owner)
|
||||
.sort(compareSessions);
|
||||
const sessionOrder = new Map(ownerSessions.map((record, index) => [record.id, index]));
|
||||
const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({
|
||||
kind: 'session',
|
||||
id: record.id,
|
||||
ownerValid: recordOwner(record) === input.owner,
|
||||
visible: true,
|
||||
order: sessionOrder.get(record.id) ?? record.createdAt,
|
||||
parentSessionId: record.parentSessionId,
|
||||
}));
|
||||
const webviewOffset = ownerSessions.length;
|
||||
input.webviews.forEach((record, index) => {
|
||||
metadata.push({
|
||||
kind: 'webview',
|
||||
id: record.id,
|
||||
ownerValid: recordOwner(record) === input.owner,
|
||||
visible: true,
|
||||
order: webviewOffset + index,
|
||||
});
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an existing owner layout, or idempotently migrate legacy flat order.
|
||||
* Unknown stored refs remain unknown to metadata and are therefore preserved.
|
||||
* No input object is mutated; validation/capacity failure is atomic.
|
||||
*/
|
||||
export function normalizeOrMigrateOwnerTabLayout(input: TabLayoutMigrationInput): TabLayoutMigrationResult {
|
||||
const sessions = collectSessions(input);
|
||||
const metadata = buildMetadata(input, sessions);
|
||||
const existing = input.layouts && Object.hasOwn(input.layouts, input.owner) ? input.layouts[input.owner] : undefined;
|
||||
if (existing) {
|
||||
const layout = normalizeTabLayout(existing, metadata);
|
||||
return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: false };
|
||||
}
|
||||
|
||||
const ownerSessions = [...sessions.values()].filter((record) => recordOwner(record) === input.owner);
|
||||
const ownerSessionById = new Map(ownerSessions.map((record) => [record.id, record]));
|
||||
const liveOwnerIds = new Set(
|
||||
input.liveSessions.filter((record) => recordOwner(record) === input.owner).map((record) => record.id)
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const orderedSessions: TabLayoutSessionRecord[] = [];
|
||||
for (const id of input.sessionOrder ?? []) {
|
||||
const record = ownerSessionById.get(id);
|
||||
if (!record || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
orderedSessions.push(record);
|
||||
}
|
||||
for (const record of ownerSessions.filter((item) => !seen.has(item.id)).sort(compareSessions)) {
|
||||
seen.add(record.id);
|
||||
orderedSessions.push(record);
|
||||
}
|
||||
|
||||
const refs: TabRef[] = orderedSessions.map((record) => {
|
||||
const manual = record.parentSessionId !== undefined && liveOwnerIds.has(record.parentSessionId);
|
||||
return manual ? { kind: 'session', id: record.id, placement: 'manual' } : { kind: 'session', id: record.id };
|
||||
});
|
||||
for (const webview of input.webviews) {
|
||||
if (recordOwner(webview) === input.owner) refs.push({ kind: 'webview', id: webview.id });
|
||||
}
|
||||
|
||||
const layout = normalizeTabLayout(
|
||||
{
|
||||
version: 0,
|
||||
groups: [],
|
||||
ungrouped: refs,
|
||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||
},
|
||||
metadata
|
||||
);
|
||||
return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: true };
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* @fileoverview Owner-scoped authoritative tab-layout coordination.
|
||||
*
|
||||
* This is the single mutation boundary between the pure layout model, persisted
|
||||
* state, live sessions, saved webviews, and SSE. Lifecycle callers describe one
|
||||
* completed server action; this service performs at most one versioned write.
|
||||
*/
|
||||
import type { StateStore } from './state-store.js';
|
||||
import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js';
|
||||
import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layout-legacy-order.js';
|
||||
import {
|
||||
flattenOwnerSessionOrder,
|
||||
materializeOrphans,
|
||||
normalizeTabLayout,
|
||||
TabLayoutValidationError,
|
||||
validateTabLayout,
|
||||
type TabLayout,
|
||||
type TabRef,
|
||||
type TabRefMetadata,
|
||||
} from './tab-layout.js';
|
||||
import {
|
||||
normalizeOrMigrateOwnerTabLayout,
|
||||
SINGLE_USER_LAYOUT_OWNER,
|
||||
type TabLayoutSessionRecord,
|
||||
type TabLayoutWebviewRecord,
|
||||
} from './tab-layout-persistence.js';
|
||||
import { SseEvent } from './web/sse-events.js';
|
||||
|
||||
export interface TabLayoutSessionLike {
|
||||
id: string;
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
interface TabLayoutServiceDeps {
|
||||
store: Pick<
|
||||
StateStore,
|
||||
'getTabLayout' | 'getTabLayouts' | 'getSessions' | 'getSessionOrder' | 'commitTabLayoutProjection'
|
||||
>;
|
||||
sessions: ReadonlyMap<string, TabLayoutSessionLike>;
|
||||
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
|
||||
broadcast(event: string, data: unknown): void;
|
||||
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
export type TabLayoutPutResult = { status: 'updated'; layout: TabLayout } | { status: 'conflict'; layout: TabLayout };
|
||||
|
||||
export interface LegacyOrderActor {
|
||||
owner: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface SessionOrderProjectionChange {
|
||||
changedOwnerOrders: Record<string, string[]>;
|
||||
globalOrder: string[];
|
||||
globalChanged: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyOrderPutResult extends SessionOrderProjectionChange {
|
||||
order: string[];
|
||||
}
|
||||
|
||||
export interface RemovedTabLayoutSession {
|
||||
id: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
interface PreparedOwnerLayout {
|
||||
current: TabLayout | null;
|
||||
authoritative: TabLayout;
|
||||
metadata: TabRefMetadata[];
|
||||
needsReconciliationCommit: boolean;
|
||||
}
|
||||
|
||||
interface OwnerProjectionPublication {
|
||||
owner: string;
|
||||
previous: TabLayout | null;
|
||||
next: TabLayout;
|
||||
metadata: readonly TabRefMetadata[];
|
||||
excludedSessionIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PreparedOrderProjection {
|
||||
owner: string;
|
||||
previousOrder: string[];
|
||||
authoritativeBeforeIds: string[];
|
||||
excludedIds: string[];
|
||||
currentIds: string[];
|
||||
order: string[];
|
||||
}
|
||||
|
||||
const ownerOf = (record: { owner?: string }): string => record.owner ?? SINGLE_USER_LAYOUT_OWNER;
|
||||
const refKey = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
|
||||
const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
|
||||
a.length === b.length && a.every((id, index) => id === b[index]);
|
||||
|
||||
export class TabLayoutService {
|
||||
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
|
||||
private readonly ownerQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private readonly deps: TabLayoutServiceDeps) {}
|
||||
|
||||
private async withOwner<T>(owner: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.ownerQueues.get(owner) ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(task);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
this.ownerQueues.set(owner, tail);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.ownerQueues.get(owner) === tail) this.ownerQueues.delete(owner);
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire multiple owner queues in stable order so overlapping bulk cleanups cannot deadlock. */
|
||||
private async withOwners<T>(owners: readonly string[], task: () => Promise<T>, index = 0): Promise<T> {
|
||||
if (index >= owners.length) return task();
|
||||
return this.withOwner(owners[index], () => this.withOwners(owners, task, index + 1));
|
||||
}
|
||||
|
||||
markRestorationComplete(): void {
|
||||
this.restorationState = 'complete';
|
||||
}
|
||||
|
||||
markRestorationFailed(): void {
|
||||
this.restorationState = 'failed';
|
||||
}
|
||||
|
||||
markRestorationSkipped(): void {
|
||||
this.restorationState = 'skipped';
|
||||
}
|
||||
|
||||
assertDeletionReady(): void {
|
||||
if (this.restorationState === 'complete' || this.restorationState === 'skipped') return;
|
||||
throw new Error(`Tab layout restoration is ${this.restorationState}; destructive deletion is unavailable`);
|
||||
}
|
||||
|
||||
/** Repair/migrate every owner visible after startup restoration. */
|
||||
async reconcileAfterRestoration(): Promise<void> {
|
||||
if (this.restorationState !== 'complete') return;
|
||||
const { persisted, live } = this.sessionRecords();
|
||||
const webviews = await this.deps.readWebviews();
|
||||
const owners = new Set<string>();
|
||||
for (const record of [...persisted, ...live, ...webviews]) owners.add(ownerOf(record));
|
||||
for (const owner of owners) await this.get(owner);
|
||||
}
|
||||
|
||||
private sessionRecords(): { persisted: TabLayoutSessionRecord[]; live: TabLayoutSessionRecord[] } {
|
||||
const persisted = Object.entries(this.deps.store.getSessions()).map(([id, record]) => ({
|
||||
id,
|
||||
owner: record.owner,
|
||||
createdAt: record.createdAt,
|
||||
parentSessionId: record.parentSessionId,
|
||||
}));
|
||||
const live = [...this.deps.sessions.values()].map((record) => ({
|
||||
id: record.id,
|
||||
owner: record.owner,
|
||||
createdAt: record.createdAt,
|
||||
parentSessionId: record.parentSessionId,
|
||||
}));
|
||||
return { persisted, live };
|
||||
}
|
||||
|
||||
private async facts(owner: string): Promise<{
|
||||
persisted: TabLayoutSessionRecord[];
|
||||
live: TabLayoutSessionRecord[];
|
||||
webviews: readonly TabLayoutWebviewRecord[];
|
||||
metadata: TabRefMetadata[];
|
||||
}> {
|
||||
const { persisted, live } = this.sessionRecords();
|
||||
const webviews = await this.deps.readWebviews();
|
||||
const sessions = new Map<string, TabLayoutSessionRecord>();
|
||||
for (const record of persisted) sessions.set(record.id, record);
|
||||
for (const record of live) sessions.set(record.id, record);
|
||||
const ownedSessions = [...sessions.values()]
|
||||
.filter((record) => ownerOf(record) === owner)
|
||||
.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index]));
|
||||
const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({
|
||||
kind: 'session',
|
||||
id: record.id,
|
||||
ownerValid: ownerOf(record) === owner,
|
||||
visible: true,
|
||||
order: sessionOrder.get(record.id) ?? record.createdAt,
|
||||
parentSessionId: record.parentSessionId,
|
||||
}));
|
||||
const offset = ownedSessions.length;
|
||||
webviews.forEach((record, index) =>
|
||||
metadata.push({
|
||||
kind: 'webview',
|
||||
id: record.id,
|
||||
ownerValid: ownerOf(record) === owner,
|
||||
visible: true,
|
||||
order: offset + index,
|
||||
})
|
||||
);
|
||||
return { persisted, live, webviews, metadata };
|
||||
}
|
||||
|
||||
private prepareCommit(base: TabLayout, next: TabLayout): TabLayout {
|
||||
return validateTabLayout({
|
||||
...next,
|
||||
version: base.version + 1,
|
||||
updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(),
|
||||
});
|
||||
}
|
||||
|
||||
private prepareOrderProjection(item: OwnerProjectionPublication): PreparedOrderProjection {
|
||||
const excluded = item.excludedSessionIds ?? new Set<string>();
|
||||
const authoritativeBeforeIds = item.metadata
|
||||
.filter((fact) => fact.kind === 'session' && fact.ownerValid && fact.visible)
|
||||
.map((fact) => fact.id);
|
||||
const facts = authoritativeBeforeIds.filter((id) => !excluded.has(id));
|
||||
const visible = new Set(facts);
|
||||
const rawPrevious = item.previous ? flattenOwnerSessionOrder(item.previous) : [];
|
||||
const rawNext = flattenOwnerSessionOrder(item.next);
|
||||
const previousOrder = rawPrevious.filter((id) => visible.has(id) || excluded.has(id));
|
||||
const order = rawNext.filter((id) => visible.has(id) && !excluded.has(id));
|
||||
const excludedIds = normalizeSessionOrder([...excluded]);
|
||||
return {
|
||||
owner: item.owner,
|
||||
previousOrder,
|
||||
authoritativeBeforeIds: normalizeSessionOrder([...authoritativeBeforeIds, ...excluded]),
|
||||
excludedIds,
|
||||
currentIds: normalizeSessionOrder([...order, ...facts]),
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
private projectOrder(
|
||||
latest: readonly string[],
|
||||
projections: readonly PreparedOrderProjection[],
|
||||
preferred?: readonly string[]
|
||||
): string[] {
|
||||
const before = normalizeSessionOrder(latest);
|
||||
const removed = new Set(
|
||||
projections.flatMap((projection) => projection.excludedIds.filter((id) => !projection.currentIds.includes(id)))
|
||||
);
|
||||
return recomposeGlobalSessionOrder(
|
||||
before.filter((id) => !removed.has(id)),
|
||||
projections.map((projection) => ({
|
||||
owner: projection.owner,
|
||||
ownedIds: projection.currentIds,
|
||||
order: projection.order,
|
||||
})),
|
||||
preferred
|
||||
);
|
||||
}
|
||||
|
||||
private publish(
|
||||
layouts: Readonly<Record<string, TabLayout>>,
|
||||
publications: readonly OwnerProjectionPublication[],
|
||||
preferred?: readonly string[]
|
||||
): SessionOrderProjectionChange {
|
||||
const projections = publications.map((item) => this.prepareOrderProjection(item));
|
||||
let beforeOrder: string[] = [];
|
||||
const accepted = this.deps.store.commitTabLayoutProjection(layouts, (latest) => {
|
||||
beforeOrder = normalizeSessionOrder(latest);
|
||||
return this.projectOrder(beforeOrder, projections, preferred);
|
||||
});
|
||||
const changedEntries: Array<[string, string[]]> = [];
|
||||
for (const projection of projections) {
|
||||
const beforeIds = new Set(projection.authoritativeBeforeIds);
|
||||
const currentIds = new Set(projection.currentIds);
|
||||
const persistedBefore = beforeOrder.filter((id) => beforeIds.has(id));
|
||||
const persistedAfter = accepted.sessionOrder.filter((id) => currentIds.has(id));
|
||||
const layoutOrderChanged = !sameOrder(projection.previousOrder, projection.order);
|
||||
const persistedOwnerSliceChanged = !sameOrder(persistedBefore, persistedAfter);
|
||||
if (layoutOrderChanged || persistedOwnerSliceChanged) {
|
||||
changedEntries.push([projection.owner, persistedAfter]);
|
||||
}
|
||||
}
|
||||
const change: SessionOrderProjectionChange = {
|
||||
changedOwnerOrders: Object.fromEntries(changedEntries),
|
||||
globalOrder: [...accepted.sessionOrder],
|
||||
globalChanged: !sameOrder(beforeOrder, accepted.sessionOrder),
|
||||
};
|
||||
for (const [owner, layout] of Object.entries(accepted.layouts)) {
|
||||
this.deps.broadcast(SseEvent.TabLayoutChanged, { owner, version: layout.version });
|
||||
}
|
||||
if (changedEntries.length > 0 || change.globalChanged) this.deps.broadcastSessionOrder(change);
|
||||
return change;
|
||||
}
|
||||
|
||||
private commit(
|
||||
owner: string,
|
||||
base: TabLayout,
|
||||
next: TabLayout,
|
||||
metadata: readonly TabRefMetadata[],
|
||||
previous: TabLayout | null = base.version < 0 ? null : base
|
||||
): TabLayout {
|
||||
const stored = this.prepareCommit(base, next);
|
||||
this.publish({ [owner]: stored }, [{ owner, previous, next: stored, metadata }]);
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async prepareUnlocked(owner: string): Promise<PreparedOwnerLayout> {
|
||||
const facts = await this.facts(owner);
|
||||
const current = this.deps.store.getTabLayout(owner);
|
||||
const authoritative = normalizeOrMigrateOwnerTabLayout({
|
||||
owner,
|
||||
layouts: current ? { [owner]: current } : undefined,
|
||||
sessionOrder: this.deps.store.getSessionOrder(),
|
||||
persistedSessions: facts.persisted,
|
||||
liveSessions: facts.live,
|
||||
webviews: facts.webviews,
|
||||
updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(),
|
||||
}).layout;
|
||||
return {
|
||||
current,
|
||||
authoritative,
|
||||
metadata: facts.metadata,
|
||||
needsReconciliationCommit: !current || !sameLayout(current, authoritative),
|
||||
};
|
||||
}
|
||||
|
||||
private async getUnlocked(owner: string): Promise<TabLayout> {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
if (!prepared.needsReconciliationCommit) {
|
||||
const publication = {
|
||||
owner,
|
||||
previous: prepared.current,
|
||||
next: prepared.authoritative,
|
||||
metadata: prepared.metadata,
|
||||
};
|
||||
const latest = this.deps.store.getSessionOrder();
|
||||
const projected = this.projectOrder(latest, [this.prepareOrderProjection(publication)]);
|
||||
if (!sameOrder(normalizeSessionOrder(latest), projected)) this.publish({}, [publication]);
|
||||
return prepared.authoritative;
|
||||
}
|
||||
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
|
||||
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
|
||||
}
|
||||
|
||||
async get(owner: string): Promise<TabLayout> {
|
||||
return this.withOwner(owner, () => this.getUnlocked(owner));
|
||||
}
|
||||
|
||||
async put(owner: string, desired: unknown, baseVersion: number): Promise<TabLayoutPutResult> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
if (baseVersion !== prepared.authoritative.version) return { status: 'conflict', layout: prepared.authoritative };
|
||||
const validated = validateTabLayout(desired);
|
||||
const owned = new Set(prepared.metadata.filter((item) => item.ownerValid && item.visible).map(refKey));
|
||||
const refs = [...validated.groups.flatMap((group) => group.refs), ...validated.ungrouped];
|
||||
const invalid = refs.find((ref) => !owned.has(refKey(ref)));
|
||||
if (invalid)
|
||||
throw new TabLayoutValidationError(`ref is not owned by layout owner: ${invalid.kind}:${invalid.id}`);
|
||||
const normalized = normalizeTabLayout(
|
||||
{ ...validated, version: prepared.authoritative.version },
|
||||
prepared.metadata
|
||||
);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: this.commit(owner, prepared.authoritative, normalized, prepared.metadata, prepared.current),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async putLegacyOrder(actor: LegacyOrderActor, requested: readonly string[]): Promise<LegacyOrderPutResult> {
|
||||
return actor.isAdmin ? this.putAdminLegacyOrder(requested) : this.putOwnerLegacyOrder(actor.owner, requested);
|
||||
}
|
||||
|
||||
private async putOwnerLegacyOrder(owner: string, requested: readonly string[]): Promise<LegacyOrderPutResult> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
const normalized = normalizeSessionOrder(requested);
|
||||
const visible = new Set(
|
||||
prepared.metadata
|
||||
.filter((item) => item.kind === 'session' && item.ownerValid && item.visible)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
// Unknown or foreign ids are DROPPED, never a 400: the browser debounces
|
||||
// its reorder push (and swallows errors), so a session deleted inside
|
||||
// that window would otherwise cost the user the whole reorder — and the
|
||||
// endpoint sits on the stable /api/v1 surface, where the pre-layout
|
||||
// server merged leniently. Same philosophy as resolveParentSessionId.
|
||||
const requestedVisible = normalized.filter((id) => visible.has(id));
|
||||
const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id));
|
||||
const effective = mergeSessionOrder(requestedVisible, currentKnown);
|
||||
const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata);
|
||||
const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked);
|
||||
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
|
||||
const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative;
|
||||
const change = this.publish(needsLayout ? { [owner]: next } : {}, [
|
||||
{ owner, previous: prepared.current, next, metadata: prepared.metadata },
|
||||
]);
|
||||
return { order: flattenOwnerSessionOrder(next).filter((id) => visible.has(id)), ...change };
|
||||
});
|
||||
}
|
||||
|
||||
private async putAdminLegacyOrder(requested: readonly string[]): Promise<LegacyOrderPutResult> {
|
||||
const discoverOwners = (): string[] => {
|
||||
const owners = new Set(Object.keys(this.deps.store.getTabLayouts()));
|
||||
const { persisted, live } = this.sessionRecords();
|
||||
for (const record of [...persisted, ...live]) owners.add(ownerOf(record));
|
||||
return [...owners].sort();
|
||||
};
|
||||
for (;;) {
|
||||
const owners = discoverOwners();
|
||||
const result = await this.withOwners(owners, async (): Promise<LegacyOrderPutResult | null> => {
|
||||
if (!sameOrder(owners, discoverOwners())) return null;
|
||||
const normalized = normalizeSessionOrder(requested);
|
||||
const knownOwners = new Map<string, string>();
|
||||
const { persisted, live } = this.sessionRecords();
|
||||
for (const record of persisted) knownOwners.set(record.id, ownerOf(record));
|
||||
for (const record of live) knownOwners.set(record.id, ownerOf(record));
|
||||
// Unknown ids are DROPPED, never a 400 — see putOwnerLegacyOrder. In
|
||||
// single-user mode every request is the synthetic admin, so this path
|
||||
// IS the one the browser's debounced (error-swallowing) push hits.
|
||||
const known = normalized.filter((id) => knownOwners.has(id));
|
||||
|
||||
const publications: OwnerProjectionPublication[] = [];
|
||||
const updates: Record<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
|
||||
for (const owner of owners) {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
const visible = new Set(
|
||||
prepared.metadata
|
||||
.filter((item) => item.kind === 'session' && item.ownerValid && item.visible)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const requestedOwner = known.filter((id) => visible.has(id));
|
||||
const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id));
|
||||
const effective = mergeSessionOrder(requestedOwner, currentKnown);
|
||||
const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata);
|
||||
const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked);
|
||||
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
|
||||
const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative;
|
||||
if (needsLayout) updates[owner] = next;
|
||||
publications.push({ owner, previous: prepared.current, next, metadata: prepared.metadata });
|
||||
}
|
||||
const change = this.publish(updates, publications, known);
|
||||
return { order: [...change.globalOrder], ...change };
|
||||
});
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile one completed session creation into one versioned mutation. */
|
||||
async sessionCreated(owner: string): Promise<TabLayout> {
|
||||
return this.get(owner);
|
||||
}
|
||||
|
||||
/** Reconcile one completed saved-webview creation into one versioned mutation. */
|
||||
async webviewCreated(owner: string): Promise<TabLayout> {
|
||||
return this.get(owner);
|
||||
}
|
||||
|
||||
async sessionsRemoved(removed: readonly RemovedTabLayoutSession[]): Promise<void> {
|
||||
if (this.restorationState !== 'complete' || removed.length === 0) return;
|
||||
const byOwner = new Map<string, string[]>();
|
||||
for (const item of removed) {
|
||||
const owner = ownerOf(item);
|
||||
const ids = byOwner.get(owner) ?? [];
|
||||
ids.push(item.id);
|
||||
byOwner.set(owner, ids);
|
||||
}
|
||||
const owners = [...byOwner.keys()].sort();
|
||||
await this.withOwners(owners, async () => {
|
||||
const publications: OwnerProjectionPublication[] = [];
|
||||
const updates: Record<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
|
||||
for (const owner of owners) {
|
||||
const ids = byOwner.get(owner) ?? [];
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
const current = prepared.current;
|
||||
// Normalize and prune together so stale cleanup, orphan materialization,
|
||||
// and missing-ref repair remain one versioned server mutation.
|
||||
const next = normalizeTabLayout(
|
||||
materializeOrphans(prepared.authoritative, ids, prepared.metadata),
|
||||
prepared.metadata
|
||||
);
|
||||
const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null;
|
||||
if (stored) updates[owner] = stored;
|
||||
publications.push({
|
||||
owner,
|
||||
previous: current,
|
||||
next: stored ?? next,
|
||||
metadata: prepared.metadata,
|
||||
excludedSessionIds: new Set(ids),
|
||||
});
|
||||
}
|
||||
if (publications.length > 0) this.publish(updates, publications);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the owner mutation lock across an irreversible session deletion.
|
||||
* All failure-prone normalization happens before `action`; the prepared layout
|
||||
* commits only after the resource cleanup finishes.
|
||||
*/
|
||||
async runSessionDeletion<T>(removed: readonly RemovedTabLayoutSession[], action: () => Promise<T>): Promise<T> {
|
||||
// A failed restoration must not lock the user out of explicitly closing a
|
||||
// tab for the rest of the process lifetime: degrade to best-effort deletion
|
||||
// without layout coordination. Only the AUTOMATED stale sweep stays
|
||||
// fail-closed on 'failed' (runStaleSessionCleanup), because that one picks
|
||||
// its victims itself from state a failed restore may have left incomplete.
|
||||
if (this.restorationState === 'failed') return action();
|
||||
this.assertDeletionReady();
|
||||
if (this.restorationState === 'skipped' || removed.length === 0) return action();
|
||||
const owners = new Set(removed.map(ownerOf));
|
||||
if (owners.size !== 1) throw new Error('A session deletion transaction must contain exactly one owner');
|
||||
const owner = owners.values().next().value as string;
|
||||
const ids = removed.map((item) => item.id);
|
||||
return this.withOwner(owner, async () => {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
const current = prepared.current;
|
||||
// Prepare while the soon-to-be-deleted sessions are still known, so
|
||||
// direct children can be materialized before their parent ref is removed.
|
||||
const next = materializeOrphans(prepared.authoritative, ids, prepared.metadata);
|
||||
const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null;
|
||||
const result = await action();
|
||||
this.publish(stored ? { [owner]: stored } : {}, [
|
||||
{
|
||||
owner,
|
||||
previous: current,
|
||||
next: stored ?? next,
|
||||
metadata: prepared.metadata,
|
||||
excludedSessionIds: new Set(ids),
|
||||
},
|
||||
]);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare every affected owner layout before bulk stale-state deletion.
|
||||
* The StateStore action remains synchronous in production, so the candidate
|
||||
* snapshot cannot change between successful preparation and resource removal.
|
||||
*/
|
||||
async runStaleSessionCleanup<T>(
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
action: (ids: ReadonlySet<string>) => T | Promise<T>
|
||||
): Promise<T> {
|
||||
this.assertDeletionReady();
|
||||
const candidates = Object.entries(this.deps.store.getSessions())
|
||||
.filter(([id, record]) => !activeSessionIds.has(id) && record.pinned !== true)
|
||||
.map(([id, record]) => ({ id, owner: record.owner }));
|
||||
if (this.restorationState === 'skipped') return action(new Set(candidates.map((item) => item.id)));
|
||||
if (candidates.length === 0) return action(new Set());
|
||||
|
||||
const byOwner = new Map<string, string[]>();
|
||||
for (const item of candidates) {
|
||||
const owner = ownerOf(item);
|
||||
const ids = byOwner.get(owner) ?? [];
|
||||
ids.push(item.id);
|
||||
byOwner.set(owner, ids);
|
||||
}
|
||||
const owners = [...byOwner.keys()].sort();
|
||||
return this.withOwners(owners, async () => {
|
||||
const webviews = await this.deps.readWebviews();
|
||||
const persistedState = this.deps.store.getSessions();
|
||||
const persisted = Object.entries(persistedState).map(([id, record]) => ({
|
||||
id,
|
||||
owner: record.owner,
|
||||
createdAt: record.createdAt,
|
||||
parentSessionId: record.parentSessionId,
|
||||
}));
|
||||
const liveIds = new Set(this.deps.sessions.keys());
|
||||
const confirmed = candidates.filter((candidate) => {
|
||||
const record = persistedState[candidate.id];
|
||||
return (
|
||||
record !== undefined &&
|
||||
ownerOf(record) === ownerOf(candidate) &&
|
||||
record.pinned !== true &&
|
||||
!activeSessionIds.has(candidate.id) &&
|
||||
!liveIds.has(candidate.id)
|
||||
);
|
||||
});
|
||||
const confirmedByOwner = new Map<string, string[]>();
|
||||
for (const item of confirmed) {
|
||||
const owner = ownerOf(item);
|
||||
const ids = confirmedByOwner.get(owner) ?? [];
|
||||
ids.push(item.id);
|
||||
confirmedByOwner.set(owner, ids);
|
||||
}
|
||||
|
||||
const prepared: Array<{
|
||||
owner: string;
|
||||
current: TabLayout | null;
|
||||
next: TabLayout;
|
||||
stored: TabLayout | null;
|
||||
metadata: TabRefMetadata[];
|
||||
excludedSessionIds: ReadonlySet<string>;
|
||||
}> = [];
|
||||
for (const owner of owners) {
|
||||
const ids = confirmedByOwner.get(owner) ?? [];
|
||||
if (ids.length === 0) continue;
|
||||
const current = this.deps.store.getTabLayout(owner);
|
||||
const sessions = new Map<string, TabLayoutSessionRecord>();
|
||||
for (const record of persisted) sessions.set(record.id, record);
|
||||
for (const record of this.deps.sessions.values()) sessions.set(record.id, record);
|
||||
const ownedSessions = [...sessions.values()]
|
||||
.filter((record) => ownerOf(record) === owner)
|
||||
.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index]));
|
||||
const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({
|
||||
kind: 'session',
|
||||
id: record.id,
|
||||
ownerValid: ownerOf(record) === owner,
|
||||
visible: true,
|
||||
order: sessionOrder.get(record.id) ?? record.createdAt,
|
||||
parentSessionId: record.parentSessionId,
|
||||
}));
|
||||
const offset = ownedSessions.length;
|
||||
webviews.forEach((record, index) =>
|
||||
metadata.push({
|
||||
kind: 'webview',
|
||||
id: record.id,
|
||||
ownerValid: ownerOf(record) === owner,
|
||||
visible: true,
|
||||
order: offset + index,
|
||||
})
|
||||
);
|
||||
const authoritative = normalizeOrMigrateOwnerTabLayout({
|
||||
owner,
|
||||
layouts: current ? { [owner]: current } : undefined,
|
||||
sessionOrder: this.deps.store.getSessionOrder(),
|
||||
persistedSessions: persisted,
|
||||
liveSessions: [...this.deps.sessions.values()],
|
||||
webviews,
|
||||
updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(),
|
||||
}).layout;
|
||||
const next = materializeOrphans(authoritative, ids, metadata);
|
||||
prepared.push({
|
||||
owner,
|
||||
current,
|
||||
next,
|
||||
stored: current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null,
|
||||
metadata,
|
||||
excludedSessionIds: new Set(ids),
|
||||
});
|
||||
}
|
||||
|
||||
const result = await action(new Set(confirmed.map((item) => item.id)));
|
||||
if (prepared.length > 0) {
|
||||
this.publish(
|
||||
Object.fromEntries(prepared.filter((item) => item.stored).map((item) => [item.owner, item.stored!])),
|
||||
prepared.map((item) => ({
|
||||
owner: item.owner,
|
||||
previous: item.current,
|
||||
next: item.stored ?? item.next,
|
||||
metadata: item.metadata,
|
||||
excludedSessionIds: item.excludedSessionIds,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async webviewDeleted(owner: string, id: string): Promise<void> {
|
||||
// Same explicit-user-action escape hatch as runSessionDeletion: a failed
|
||||
// restore skips layout coordination instead of failing the delete.
|
||||
if (this.restorationState === 'failed') return;
|
||||
this.assertDeletionReady();
|
||||
if (this.restorationState === 'skipped') return;
|
||||
await this.withOwner(owner, async () => {
|
||||
const current = this.deps.store.getTabLayout(owner);
|
||||
if (!current) return;
|
||||
const strip = (refs: readonly TabRef[]): TabRef[] =>
|
||||
refs.filter((ref) => ref.kind !== 'webview' || ref.id !== id).map((ref) => ({ ...ref }));
|
||||
const stripped: TabLayout = {
|
||||
...current,
|
||||
groups: current.groups.map((group) => ({ ...group, refs: strip(group.refs) })),
|
||||
ungrouped: strip(current.ungrouped),
|
||||
};
|
||||
const { metadata } = await this.facts(owner);
|
||||
const next = normalizeTabLayout(stripped, metadata);
|
||||
if (!sameLayout(current, next)) this.commit(owner, current, next, metadata);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* @fileoverview Framework-independent tab layout model.
|
||||
*
|
||||
* Callers provide owner-scoped session/webview metadata. This module deliberately
|
||||
* has no dependency on session runtime, persistence, routes, or browser state.
|
||||
*/
|
||||
|
||||
export const MAX_TAB_GROUPS = 32;
|
||||
export const MAX_TAB_GROUP_NAME_LENGTH = 60;
|
||||
export const MAX_TAB_REFS = 512;
|
||||
|
||||
export type TabRefKind = 'session' | 'webview';
|
||||
|
||||
export interface TabRef {
|
||||
kind: TabRefKind;
|
||||
id: string;
|
||||
placement?: 'manual';
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
refs: TabRef[];
|
||||
}
|
||||
|
||||
export interface TabLayout {
|
||||
version: number;
|
||||
groups: TabGroup[];
|
||||
ungrouped: TabRef[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Owner and lineage facts supplied by the server or browser integration. */
|
||||
export interface TabRefMetadata {
|
||||
kind: TabRefKind;
|
||||
id: string;
|
||||
/** False for missing, foreign-owned, or otherwise invalid refs. */
|
||||
ownerValid: boolean;
|
||||
/** False when the owner is not permitted to see/store this ref. */
|
||||
visible: boolean;
|
||||
/** Stable creation/sibling order. Ties fall back to kind and id. */
|
||||
order: number;
|
||||
/** Session-only lineage hint. Ignored for webviews. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
export interface TabMoveTarget {
|
||||
/** Null denotes the real ungrouped container. */
|
||||
groupId: string | null;
|
||||
/** Zero-based insertion index after removing the moved block. */
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface CreateTabGroupInput {
|
||||
id: string;
|
||||
name: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface VisibleTabProjectionOptions {
|
||||
liveSessionIds: ReadonlySet<string>;
|
||||
openWebviewIds: ReadonlySet<string>;
|
||||
collapsedGroupIds?: ReadonlySet<string>;
|
||||
highlighted?: TabRef;
|
||||
}
|
||||
|
||||
export class TabLayoutValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TabLayoutValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
const keyOf = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
|
||||
|
||||
function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TabLayoutValidationError(`${label} must be an object`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNonEmptyString(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new TabLayoutValidationError(`${label} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseName(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string') throw new TabLayoutValidationError(`${label} must be a string`);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_TAB_GROUP_NAME_LENGTH) {
|
||||
throw new TabLayoutValidationError(`${label} must be 1-${MAX_TAB_GROUP_NAME_LENGTH} trimmed characters`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseRef(value: unknown, label: string): TabRef {
|
||||
assertRecord(value, label);
|
||||
if (value.kind !== 'session' && value.kind !== 'webview') {
|
||||
throw new TabLayoutValidationError(`${label}.kind must be session or webview`);
|
||||
}
|
||||
const id = parseNonEmptyString(value.id, `${label}.id`);
|
||||
if (value.placement !== undefined && value.placement !== 'manual') {
|
||||
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
|
||||
}
|
||||
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
|
||||
}
|
||||
|
||||
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
|
||||
assertRecord(input, 'layout');
|
||||
if (!Number.isSafeInteger(input.version) || (input.version as number) < 0) {
|
||||
throw new TabLayoutValidationError('layout.version must be a non-negative safe integer');
|
||||
}
|
||||
if (!Array.isArray(input.groups)) throw new TabLayoutValidationError('layout.groups must be an array');
|
||||
if (input.groups.length > MAX_TAB_GROUPS) {
|
||||
throw new TabLayoutValidationError(`layout.groups cannot exceed ${MAX_TAB_GROUPS}`);
|
||||
}
|
||||
if (!Array.isArray(input.ungrouped)) throw new TabLayoutValidationError('layout.ungrouped must be an array');
|
||||
const updatedAt = parseNonEmptyString(input.updatedAt, 'layout.updatedAt');
|
||||
const groupIds = new Set<string>();
|
||||
const refKeys = new Set<string>();
|
||||
let refCount = input.ungrouped.length;
|
||||
const parseStoredRef = (entry: unknown, label: string): TabRef => {
|
||||
const ref = parseRef(entry, label);
|
||||
const key = keyOf(ref);
|
||||
if (!repairDuplicates && refKeys.has(key)) {
|
||||
throw new TabLayoutValidationError(`duplicate ref: ${ref.kind}:${ref.id}`);
|
||||
}
|
||||
refKeys.add(key);
|
||||
return ref;
|
||||
};
|
||||
const groups = input.groups.map((rawGroup, groupIndex): TabGroup => {
|
||||
const label = `layout.groups[${groupIndex}]`;
|
||||
assertRecord(rawGroup, label);
|
||||
const id = parseNonEmptyString(rawGroup.id, `${label}.id`);
|
||||
if (groupIds.has(id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`);
|
||||
groupIds.add(id);
|
||||
if (!Array.isArray(rawGroup.refs)) throw new TabLayoutValidationError(`${label}.refs must be an array`);
|
||||
refCount += rawGroup.refs.length;
|
||||
return {
|
||||
id,
|
||||
name: parseName(rawGroup.name, `${label}.name`),
|
||||
refs: rawGroup.refs.map((entry, refIndex) => parseStoredRef(entry, `${label}.refs[${refIndex}]`)),
|
||||
};
|
||||
});
|
||||
if (refCount > MAX_TAB_REFS) {
|
||||
throw new TabLayoutValidationError(`layout cannot contain more than ${MAX_TAB_REFS} refs`);
|
||||
}
|
||||
return {
|
||||
version: input.version as number,
|
||||
groups,
|
||||
ungrouped: input.ungrouped.map((entry, index) => parseStoredRef(entry, `layout.ungrouped[${index}]`)),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate and defensively clone a layout. Group names are normalized by trimming. */
|
||||
export function validateTabLayout(input: unknown): TabLayout {
|
||||
return parseTabLayout(input, false);
|
||||
}
|
||||
|
||||
function validMetadata(metadata: readonly TabRefMetadata[]): TabRefMetadata[] {
|
||||
const byKey = new Map<string, TabRefMetadata>();
|
||||
for (const item of metadata) {
|
||||
if ((item.kind !== 'session' && item.kind !== 'webview') || typeof item.id !== 'string' || item.id.length === 0) {
|
||||
throw new TabLayoutValidationError('metadata contains an invalid ref identity');
|
||||
}
|
||||
if (!Number.isFinite(item.order)) throw new TabLayoutValidationError(`metadata order is invalid for ${item.id}`);
|
||||
if (!item.ownerValid || !item.visible) continue;
|
||||
const key = keyOf(item);
|
||||
if (!byKey.has(key)) byKey.set(key, { ...item });
|
||||
}
|
||||
const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const result = [...byKey.values()].sort(
|
||||
(a, b) => a.order - b.order || compareText(a.kind, b.kind) || compareText(a.id, b.id)
|
||||
);
|
||||
if (result.length > MAX_TAB_REFS) {
|
||||
throw new TabLayoutValidationError(`owner layout cannot exceed ${MAX_TAB_REFS} refs`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface LocatedRef {
|
||||
ref: TabRef;
|
||||
container: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
function locations(layout: TabLayout): LocatedRef[] {
|
||||
const result: LocatedRef[] = [];
|
||||
let position = 0;
|
||||
for (const group of layout.groups) {
|
||||
for (const ref of group.refs) result.push({ ref, container: group.id, position: position++ });
|
||||
}
|
||||
for (const ref of layout.ungrouped) result.push({ ref, container: null, position: position++ });
|
||||
return result;
|
||||
}
|
||||
|
||||
function withContainers(layout: TabLayout, refsByContainer: ReadonlyMap<string | null, TabRef[]>): TabLayout {
|
||||
return {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) => ({ ...group, refs: [...(refsByContainer.get(group.id) ?? [])] })),
|
||||
ungrouped: [...(refsByContainer.get(null) ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a layout against owner-valid metadata and session lineage.
|
||||
* First stored occurrence wins; missing valid refs append to ungrouped.
|
||||
*/
|
||||
export function normalizeTabLayout(input: TabLayout, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const layout = parseTabLayout(input, true);
|
||||
const valid = validMetadata(metadata);
|
||||
const metadataByKey = new Map(valid.map((item) => [keyOf(item), item]));
|
||||
const knownMetadataKeys = new Set(metadata.map((item) => keyOf(item)));
|
||||
const seen = new Set<string>();
|
||||
const dedupedByContainer = new Map<string | null, TabRef[]>();
|
||||
for (const group of layout.groups) dedupedByContainer.set(group.id, []);
|
||||
dedupedByContainer.set(null, []);
|
||||
|
||||
for (const located of locations(layout)) {
|
||||
const key = keyOf(located.ref);
|
||||
// Missing metadata is unknown rather than invalid (for example, during
|
||||
// restoration). Preserve it until an explicit invalid/deletion fact arrives.
|
||||
if ((knownMetadataKeys.has(key) && !metadataByKey.has(key)) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
dedupedByContainer.get(located.container)!.push({ ...located.ref });
|
||||
}
|
||||
for (const item of valid) {
|
||||
const key = keyOf(item);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
dedupedByContainer.get(null)!.push({ kind: item.kind, id: item.id });
|
||||
}
|
||||
if (seen.size > MAX_TAB_REFS) {
|
||||
throw new TabLayoutValidationError(`normalized layout cannot exceed ${MAX_TAB_REFS} refs`);
|
||||
}
|
||||
|
||||
let working = withContainers(layout, dedupedByContainer);
|
||||
const located = locations(working);
|
||||
const refByKey = new Map(located.map((item) => [keyOf(item.ref), item.ref]));
|
||||
const sessionById = new Map(valid.filter((item) => item.kind === 'session').map((item) => [item.id, item]));
|
||||
const manualCycleEdges = new Set<string>();
|
||||
const state = new Map<string, 'visiting' | 'done'>();
|
||||
|
||||
const visit = (id: string): void => {
|
||||
if (state.get(id) === 'done') return;
|
||||
state.set(id, 'visiting');
|
||||
const item = sessionById.get(id);
|
||||
const stored = refByKey.get(keyOf({ kind: 'session', id }));
|
||||
if (item?.parentSessionId && stored?.placement !== 'manual') {
|
||||
const parent = sessionById.get(item.parentSessionId);
|
||||
const parentStored = refByKey.get(keyOf({ kind: 'session', id: item.parentSessionId }));
|
||||
if (parent && parentStored) {
|
||||
if (state.get(parent.id) === 'visiting') manualCycleEdges.add(id);
|
||||
else visit(parent.id);
|
||||
}
|
||||
}
|
||||
state.set(id, 'done');
|
||||
};
|
||||
for (const item of located)
|
||||
if (item.ref.kind === 'session' && state.get(item.ref.id) === undefined) visit(item.ref.id);
|
||||
|
||||
if (manualCycleEdges.size > 0) {
|
||||
working = {
|
||||
...working,
|
||||
groups: working.groups.map((group) => ({
|
||||
...group,
|
||||
refs: group.refs.map((ref) =>
|
||||
ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref
|
||||
),
|
||||
})),
|
||||
ungrouped: working.ungrouped.map((ref) =>
|
||||
ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const ordered = locations(working);
|
||||
const updatedRefByKey = new Map(ordered.map((item) => [keyOf(item.ref), item.ref]));
|
||||
const parentOf = new Map<string, string>();
|
||||
const children = new Map<string, string[]>();
|
||||
for (const item of ordered) {
|
||||
if (item.ref.kind !== 'session' || item.ref.placement === 'manual') continue;
|
||||
const info = sessionById.get(item.ref.id);
|
||||
const parentId = info?.parentSessionId;
|
||||
if (!parentId || !sessionById.has(parentId) || !updatedRefByKey.has(keyOf({ kind: 'session', id: parentId })))
|
||||
continue;
|
||||
parentOf.set(item.ref.id, parentId);
|
||||
const siblings = children.get(parentId) ?? [];
|
||||
siblings.push(item.ref.id);
|
||||
children.set(parentId, siblings);
|
||||
}
|
||||
|
||||
const emitted = new Set<string>();
|
||||
const output = new Map<string | null, TabRef[]>();
|
||||
for (const group of working.groups) output.set(group.id, []);
|
||||
output.set(null, []);
|
||||
const emitSubtree = (root: TabRef, container: string | null): void => {
|
||||
const rootKey = keyOf(root);
|
||||
if (emitted.has(rootKey)) return;
|
||||
emitted.add(rootKey);
|
||||
output.get(container)!.push({ ...root });
|
||||
if (root.kind !== 'session') return;
|
||||
for (const childId of children.get(root.id) ?? []) {
|
||||
const child = updatedRefByKey.get(keyOf({ kind: 'session', id: childId }));
|
||||
if (child) emitSubtree(child, container);
|
||||
}
|
||||
};
|
||||
for (const item of ordered) {
|
||||
if (item.ref.kind === 'session' && parentOf.has(item.ref.id)) continue;
|
||||
emitSubtree(item.ref, item.container);
|
||||
}
|
||||
return withContainers(working, output);
|
||||
}
|
||||
|
||||
function cloneForEdit(input: TabLayout): TabLayout {
|
||||
return validateTabLayout(input);
|
||||
}
|
||||
|
||||
function boundedIndex(index: number, length: number, label: string): number {
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index > length) {
|
||||
throw new TabLayoutValidationError(`${label} index must be between 0 and ${length}`);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function createGroup(input: TabLayout, group: CreateTabGroupInput): TabLayout {
|
||||
const layout = cloneForEdit(input);
|
||||
if (layout.groups.length >= MAX_TAB_GROUPS)
|
||||
throw new TabLayoutValidationError(`cannot exceed ${MAX_TAB_GROUPS} groups`);
|
||||
const id = parseNonEmptyString(group.id, 'group.id');
|
||||
if (layout.groups.some((entry) => entry.id === id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`);
|
||||
const index = boundedIndex(group.index ?? layout.groups.length, layout.groups.length, 'group');
|
||||
const groups = [...layout.groups];
|
||||
groups.splice(index, 0, { id, name: parseName(group.name, 'group.name'), refs: [] });
|
||||
return { ...layout, groups };
|
||||
}
|
||||
|
||||
export function renameGroup(input: TabLayout, groupId: string, name: string): TabLayout {
|
||||
const layout = cloneForEdit(input);
|
||||
if (!layout.groups.some((group) => group.id === groupId))
|
||||
throw new TabLayoutValidationError(`unknown group: ${groupId}`);
|
||||
return {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) =>
|
||||
group.id === groupId ? { ...group, name: parseName(name, 'group.name') } : group
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroup(input: TabLayout, groupId: string): TabLayout {
|
||||
const layout = cloneForEdit(input);
|
||||
const group = layout.groups.find((entry) => entry.id === groupId);
|
||||
if (!group) throw new TabLayoutValidationError(`unknown group: ${groupId}`);
|
||||
return {
|
||||
...layout,
|
||||
groups: layout.groups.filter((entry) => entry.id !== groupId),
|
||||
ungrouped: [...layout.ungrouped, ...group.refs.map((ref) => ({ ...ref }))],
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderGroup(input: TabLayout, groupId: string, index: number): TabLayout {
|
||||
const layout = cloneForEdit(input);
|
||||
const from = layout.groups.findIndex((group) => group.id === groupId);
|
||||
if (from < 0) throw new TabLayoutValidationError(`unknown group: ${groupId}`);
|
||||
const groups = [...layout.groups];
|
||||
const [group] = groups.splice(from, 1);
|
||||
groups.splice(boundedIndex(index, groups.length, 'group'), 0, group);
|
||||
return { ...layout, groups };
|
||||
}
|
||||
|
||||
function mapRef(input: TabLayout, target: TabRef, transform: (ref: TabRef) => TabRef): TabLayout {
|
||||
const layout = cloneForEdit(input);
|
||||
let found = false;
|
||||
const apply = (ref: TabRef): TabRef => {
|
||||
if (keyOf(ref) !== keyOf(target)) return ref;
|
||||
found = true;
|
||||
return transform(ref);
|
||||
};
|
||||
const result = {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) => ({ ...group, refs: group.refs.map(apply) })),
|
||||
ungrouped: layout.ungrouped.map(apply),
|
||||
};
|
||||
if (!found) throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function setManualPlacement(input: TabLayout, target: TabRef, manual: boolean): TabLayout {
|
||||
if (!manual) {
|
||||
throw new TabLayoutValidationError('manual placement can only be cleared through followParent');
|
||||
}
|
||||
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
|
||||
}
|
||||
|
||||
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const normalized = normalizeTabLayout(input, metadata);
|
||||
if (target.kind !== 'session') {
|
||||
throw new TabLayoutValidationError('only a session ref can follow a parent');
|
||||
}
|
||||
|
||||
const valid = validMetadata(metadata);
|
||||
const targetMetadata = valid.find((item) => item.kind === 'session' && item.id === target.id);
|
||||
if (!targetMetadata?.parentSessionId) {
|
||||
throw new TabLayoutValidationError(`session has no owner-valid parent: ${target.id}`);
|
||||
}
|
||||
const parentMetadata = valid.find((item) => item.kind === 'session' && item.id === targetMetadata.parentSessionId);
|
||||
if (!parentMetadata) {
|
||||
throw new TabLayoutValidationError(`session parent is not owner-valid: ${targetMetadata.parentSessionId}`);
|
||||
}
|
||||
|
||||
const storedKeys = new Set(locations(normalized).map((item) => keyOf(item.ref)));
|
||||
if (!storedKeys.has(keyOf(target))) {
|
||||
throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
|
||||
}
|
||||
const parentRef: TabRef = { kind: 'session', id: targetMetadata.parentSessionId };
|
||||
if (!storedKeys.has(keyOf(parentRef))) {
|
||||
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
|
||||
}
|
||||
|
||||
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
|
||||
return normalizeTabLayout(cleared, metadata);
|
||||
}
|
||||
|
||||
function descendantKeys(root: TabRef, layout: TabLayout, metadata: readonly TabRefMetadata[]): Set<string> {
|
||||
const valid = validMetadata(metadata);
|
||||
const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref]));
|
||||
const children = new Map<string, string[]>();
|
||||
for (const item of valid) {
|
||||
if (item.kind !== 'session' || !item.parentSessionId) continue;
|
||||
const child = stored.get(keyOf(item));
|
||||
if (!child || child.placement === 'manual' || !stored.has(keyOf({ kind: 'session', id: item.parentSessionId })))
|
||||
continue;
|
||||
const siblings = children.get(item.parentSessionId) ?? [];
|
||||
siblings.push(item.id);
|
||||
children.set(item.parentSessionId, siblings);
|
||||
}
|
||||
const result = new Set<string>();
|
||||
const add = (ref: TabRef): void => {
|
||||
const key = keyOf(ref);
|
||||
if (result.has(key)) return;
|
||||
result.add(key);
|
||||
if (ref.kind !== 'session') return;
|
||||
for (const childId of children.get(ref.id) ?? []) add({ kind: 'session', id: childId });
|
||||
};
|
||||
add(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function moveRef(
|
||||
input: TabLayout,
|
||||
target: TabRef,
|
||||
destination: TabMoveTarget,
|
||||
metadata: readonly TabRefMetadata[]
|
||||
): TabLayout {
|
||||
let layout = normalizeTabLayout(input, metadata);
|
||||
const targetKey = keyOf(target);
|
||||
if (!locations(layout).some((item) => keyOf(item.ref) === targetKey)) {
|
||||
throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
|
||||
}
|
||||
if (destination.groupId !== null && !layout.groups.some((group) => group.id === destination.groupId)) {
|
||||
throw new TabLayoutValidationError(`unknown group: ${destination.groupId}`);
|
||||
}
|
||||
|
||||
const blockKeys = descendantKeys(target, layout, metadata);
|
||||
const block = locations(layout)
|
||||
.filter((item) => blockKeys.has(keyOf(item.ref)))
|
||||
.map((item) => ({ ...item.ref }));
|
||||
const metadataItem = validMetadata(metadata).find((item) => keyOf(item) === targetKey);
|
||||
if (target.kind === 'session' && metadataItem?.parentSessionId) block[0] = { ...block[0], placement: 'manual' };
|
||||
|
||||
const remaining = new Map<string | null, TabRef[]>();
|
||||
for (const group of layout.groups)
|
||||
remaining.set(
|
||||
group.id,
|
||||
group.refs.filter((ref) => !blockKeys.has(keyOf(ref)))
|
||||
);
|
||||
remaining.set(
|
||||
null,
|
||||
layout.ungrouped.filter((ref) => !blockKeys.has(keyOf(ref)))
|
||||
);
|
||||
const destinationRefs = remaining.get(destination.groupId)!;
|
||||
const index = boundedIndex(destination.index, destinationRefs.length, 'destination');
|
||||
destinationRefs.splice(index, 0, ...block);
|
||||
layout = withContainers(layout, remaining);
|
||||
return normalizeTabLayout(layout, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove explicitly deleted session parents and pin their direct inherited
|
||||
* children at their current stored positions so a later reused ID cannot adopt them.
|
||||
*/
|
||||
export function materializeOrphans(
|
||||
input: TabLayout,
|
||||
removedParentIds: readonly string[],
|
||||
metadata: readonly TabRefMetadata[]
|
||||
): TabLayout {
|
||||
const layout = cloneForEdit(input);
|
||||
const removed = new Set(removedParentIds);
|
||||
const directChildren = new Set(
|
||||
validMetadata(metadata)
|
||||
.filter((item) => item.kind === 'session' && item.parentSessionId && removed.has(item.parentSessionId))
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const transform = (refs: readonly TabRef[]): TabRef[] =>
|
||||
refs
|
||||
.filter((ref) => ref.kind !== 'session' || !removed.has(ref.id))
|
||||
.map((ref) =>
|
||||
ref.kind === 'session' && directChildren.has(ref.id) && ref.placement !== 'manual'
|
||||
? { ...ref, placement: 'manual' }
|
||||
: { ...ref }
|
||||
);
|
||||
return {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) => ({ ...group, refs: transform(group.refs) })),
|
||||
ungrouped: transform(layout.ungrouped),
|
||||
};
|
||||
}
|
||||
|
||||
/** Session-only compatibility order; collapse and webviews do not affect it. */
|
||||
export function flattenOwnerSessionOrder(input: TabLayout): string[] {
|
||||
return locations(validateTabLayout(input))
|
||||
.map((item) => item.ref)
|
||||
.filter((ref): ref is TabRef & { kind: 'session' } => ref.kind === 'session')
|
||||
.map((ref) => ref.id);
|
||||
}
|
||||
|
||||
/** Locally renderable order used by tab painting and Alt-number consumers. */
|
||||
export function flattenVisibleRefs(input: TabLayout, options: VisibleTabProjectionOptions): TabRef[] {
|
||||
const layout = validateTabLayout(input);
|
||||
const collapsed = options.collapsedGroupIds ?? new Set<string>();
|
||||
const renderable = (ref: TabRef): boolean =>
|
||||
ref.kind === 'session' ? options.liveSessionIds.has(ref.id) : options.openWebviewIds.has(ref.id);
|
||||
const highlightedKey = options.highlighted ? keyOf(options.highlighted) : undefined;
|
||||
const result: TabRef[] = [];
|
||||
for (const group of layout.groups) {
|
||||
for (const ref of group.refs) {
|
||||
if (!renderable(ref)) continue;
|
||||
if (collapsed.has(group.id) && keyOf(ref) !== highlightedKey) continue;
|
||||
result.push({ ...ref });
|
||||
}
|
||||
}
|
||||
for (const ref of layout.ungrouped) if (renderable(ref)) result.push({ ...ref });
|
||||
return result;
|
||||
}
|
||||
+317
-40
@@ -31,7 +31,13 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import {
|
||||
dataPath,
|
||||
DEFAULT_TMUX_SOCKET,
|
||||
CODEMAN_INSTANCE,
|
||||
SAFE_TMUX_SOCKET_PATTERN,
|
||||
resolveTmuxSocketName,
|
||||
} from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -46,6 +52,9 @@ import {
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
@@ -86,6 +95,13 @@ import {
|
||||
getAntigravityNotFoundMessage,
|
||||
resolvePiDir,
|
||||
getPiNotFoundMessage,
|
||||
resolveGrokDir,
|
||||
getGrokNotFoundMessage,
|
||||
resolveDeepSeekDir,
|
||||
getDeepSeekNotFoundMessage,
|
||||
resolveDefaultDeepSeekProfile,
|
||||
getOmpNotFoundMessage,
|
||||
resolveOmpDir,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
} from './utils/index.js';
|
||||
@@ -110,6 +126,7 @@ import {
|
||||
// ============================================================================
|
||||
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
|
||||
|
||||
/** How long a cached process snapshot stays usable. */
|
||||
const PROC_SNAPSHOT_TTL_MS = 2000;
|
||||
@@ -200,9 +217,6 @@ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||||
* `codeman` for prod, `codeman-beta` on the beta branch). */
|
||||
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||||
|
||||
/** Regex to validate tmux socket names passed to `tmux -L`. */
|
||||
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Separator used in `tmux list-panes -F` output between session name and pid.
|
||||
*
|
||||
@@ -597,9 +611,8 @@ function resolveConfiguredTmuxSocket(): string {
|
||||
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
|
||||
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
|
||||
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
|
||||
return DEFAULT_CODEMAN_TMUX_SOCKET;
|
||||
}
|
||||
return raw;
|
||||
return resolveTmuxSocketName();
|
||||
}
|
||||
|
||||
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
|
||||
@@ -800,6 +813,120 @@ function buildPiCommand(config?: PiConfig): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Grok Build CLI (xAI `grok`) command with appropriate flags.
|
||||
*
|
||||
* The bypass switch is `--always-approve` ("auto-approve all tool executions",
|
||||
* grok's `bypassPermissions` permission mode; config-level deny rules still
|
||||
* apply on top). Absent config spawns bare `grok`, i.e. grok's own default
|
||||
* ask-mode, which is why the multi-user clamp only needs the only-if-sent
|
||||
* branch for grok. Flag surface verified against grok 1.0.5.
|
||||
*
|
||||
* `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through
|
||||
* socket-scoped `tmux setenv` (envOverrides), never the spawn command line.
|
||||
*
|
||||
* Like the sibling builders, every user value is regex-allowlisted and silently
|
||||
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
|
||||
*/
|
||||
function buildGrokCommand(config?: GrokConfig): string {
|
||||
const parts = ['grok'];
|
||||
|
||||
if (config?.alwaysApprove) {
|
||||
parts.push('--always-approve');
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
// --resume and -c conflict; a valid explicit session id wins. Ids only:
|
||||
// grok's --resume also accepts session TITLES, which are arbitrary user
|
||||
// strings, so the id regex doubles as the no-titles rule here.
|
||||
const safeSessionId =
|
||||
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||||
if (safeSessionId) {
|
||||
parts.push('--resume', safeSessionId);
|
||||
} else if (config?.continueSession) {
|
||||
parts.push('--continue');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the DeepSeek Harness (`dsh`) command with appropriate flags.
|
||||
*
|
||||
* Unlike every sibling builder, the interesting decision here is not a flag but
|
||||
* WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/<name>`,
|
||||
* and DeepSeek ships no interactive terminal profile of its own, so the agent a
|
||||
* pane runs is always one the user installed. An absent `profile` resolves to
|
||||
* the first pane-capable profile on the box; when there is none we still emit a
|
||||
* bare `dsh --profile <default>` rather than inventing a name, because the
|
||||
* availability gate in createSession() has already refused the spawn by then and
|
||||
* this path only runs for a session that passed it.
|
||||
*
|
||||
* There is deliberately NO permission flag: the harness has none. The sandbox
|
||||
* and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv`
|
||||
* in buildEnvExports() so it never lands on this command line.
|
||||
*
|
||||
* Like the sibling builders, every user value is regex-allowlisted and silently
|
||||
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
|
||||
*/
|
||||
function buildDeepSeekCommand(config?: DeepSeekConfig): string {
|
||||
const parts = ['dsh'];
|
||||
|
||||
// A profile name is a single path segment: it is both interpolated into the
|
||||
// shell line and joined into a filesystem path.
|
||||
const requested = config?.profile;
|
||||
const safeProfile =
|
||||
requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested)
|
||||
? requested
|
||||
: (resolveDefaultDeepSeekProfile() ?? undefined);
|
||||
if (safeProfile) parts.push('--profile', safeProfile);
|
||||
|
||||
// The launcher forwards everything after its own flags to the profile's app,
|
||||
// which is where `--resume` is understood. An explicit id wins over the
|
||||
// most-recent-session form, mirroring the sibling builders.
|
||||
const safeSessionId =
|
||||
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||||
if (safeSessionId) {
|
||||
parts.push('--resume', safeSessionId);
|
||||
} else if (config?.resumeSession) {
|
||||
parts.push('--resume');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OMP CLI command with appropriate flags.
|
||||
*
|
||||
* omp reads its model routing and hooks from ~/.omp (agent dir), so no
|
||||
* trust/permission flags are needed: the CLI's own config governs. The only
|
||||
* CLI flags passed are the per-session overrides Codeman knows about.
|
||||
*/
|
||||
function buildOmpCommand(config?: OmpConfig): string {
|
||||
const parts = ['omp'];
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
// --resume and --continue conflict; a valid explicit session id wins,
|
||||
// mirroring the sibling builders (grok/pi/opencode).
|
||||
const safeId =
|
||||
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||||
if (safeId) {
|
||||
parts.push('--resume', safeId);
|
||||
} else if (config?.continueSession) {
|
||||
parts.push('--continue');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
@@ -843,6 +970,9 @@ export function buildSpawnCommand(options: {
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
||||
@@ -892,6 +1022,15 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'pi') {
|
||||
return buildPiCommand(options.piConfig);
|
||||
}
|
||||
if (options.mode === 'grok') {
|
||||
return buildGrokCommand(options.grokConfig);
|
||||
}
|
||||
if (options.mode === 'deepseek') {
|
||||
return buildDeepSeekCommand(options.deepSeekConfig);
|
||||
}
|
||||
if (options.mode === 'omp') {
|
||||
return buildOmpCommand(options.ompConfig);
|
||||
}
|
||||
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
||||
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
||||
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
||||
@@ -1075,7 +1214,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
|
||||
* adopts/resizes/respawns our session (same defence as the remote socket).
|
||||
*/
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable in-container tmux session name. Derived from the
|
||||
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
|
||||
@@ -1107,6 +1245,11 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
||||
return `${modeCommand} --conversation ${resumeId}`;
|
||||
case 'pi':
|
||||
return `${modeCommand} --session ${resumeId}`;
|
||||
case 'grok':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'deepseek':
|
||||
case 'omp':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
}
|
||||
@@ -1568,6 +1711,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
private reconnectGuard: Set<string> = new Set();
|
||||
|
||||
private trueColorConfigured = false;
|
||||
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
|
||||
private liveHistoryResizeSupported: boolean | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -1586,6 +1731,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return tmuxCommand(this.tmuxSocket);
|
||||
}
|
||||
|
||||
private supportsLiveHistoryResize(): boolean {
|
||||
if (this.liveHistoryResizeSupported !== null) return this.liveHistoryResizeSupported;
|
||||
|
||||
try {
|
||||
const output = execSync(`${this.tmux()} -V`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const match = output.match(/(?:^|\D)(\d+)\.(\d+)/);
|
||||
const major = match ? Number(match[1]) : 0;
|
||||
const minor = match ? Number(match[2]) : 0;
|
||||
this.liveHistoryResizeSupported = major > 3 || (major === 3 && minor >= 7);
|
||||
} catch {
|
||||
// Unknown versions take the legacy path required by tmux <3.7.
|
||||
this.liveHistoryResizeSupported = false;
|
||||
}
|
||||
return this.liveHistoryResizeSupported;
|
||||
}
|
||||
|
||||
// Load saved sessions from disk (NEVER called in test mode)
|
||||
private loadSessions(): void {
|
||||
if (IS_TEST_MODE) return;
|
||||
@@ -1675,10 +1840,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const exports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi'
|
||||
mode === 'codex' ||
|
||||
mode === 'gemini' ||
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek' ||
|
||||
mode === 'omp'
|
||||
? 'export COLORTERM=truecolor'
|
||||
: 'unset COLORTERM',
|
||||
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' ? ['unset NO_COLOR'] : []),
|
||||
...(mode === 'codex' ||
|
||||
mode === 'gemini' ||
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek' ||
|
||||
mode === 'omp'
|
||||
? ['unset NO_COLOR']
|
||||
: []),
|
||||
// Stamp each Codex pane with a unique originator so the response-viewer
|
||||
// can locate THIS pane's rollout exactly — codex writes the value into
|
||||
// session_meta.originator of every rollout it creates. Without it,
|
||||
@@ -1773,6 +1952,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dir = resolvePiDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'grok') {
|
||||
const dir = resolveGrokDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'deepseek') {
|
||||
const dir = resolveDeepSeekDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
const dir = resolveOmpDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
@@ -1803,6 +1994,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
setGeminiEnvVars(this.tmux(), muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure DeepSeek Harness environment on a tmux session.
|
||||
*
|
||||
* Two independent things, both via `tmux setenv` so they are inherited by the
|
||||
* pane without appearing in `ps`:
|
||||
*
|
||||
* 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported
|
||||
* ONLY when the caller sent one, so an absent config lands on the harness's
|
||||
* own `workspace-write` default (which asks) rather than on ours. That
|
||||
* "only if sent" shape is what the multi-user clamp relies on.
|
||||
* 2. The `HERDR_*` triple — the supervisor contract the terminal front door
|
||||
* uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own
|
||||
* generated shim is what upgrades this mode from output-stabilization
|
||||
* guessing to definitive hook events (see deepseek-status-shim.ts). The
|
||||
* pane id IS the Codeman session id, which is how the shim attributes a
|
||||
* report without trusting anything the agent could influence.
|
||||
*
|
||||
* Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when
|
||||
* present, matching the codex/gemini precedent for headless auth.
|
||||
*/
|
||||
private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void {
|
||||
const tmuxCmd = this.tmux();
|
||||
const setenv = (key: string, value: string): void => {
|
||||
const escaped = value.replace(/'/g, "'\\''");
|
||||
try {
|
||||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
/* Non-critical */
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) {
|
||||
const val = process.env[key];
|
||||
if (val) setenv(key, val);
|
||||
}
|
||||
|
||||
// Enum-validated at the schema boundary; re-checked here because this value
|
||||
// reaches a shell line, and a builder must never trust its caller.
|
||||
if (
|
||||
config?.permissionMode &&
|
||||
['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode)
|
||||
) {
|
||||
setenv('DSH_PERMISSION_MODE', config.permissionMode);
|
||||
}
|
||||
|
||||
if (config?.statusReporting !== false) {
|
||||
const shim = ensureDeepSeekStatusShim();
|
||||
if (shim) {
|
||||
setenv('HERDR_ENV', '1');
|
||||
setenv('HERDR_BIN_PATH', shim);
|
||||
setenv('HERDR_PANE_ID', sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
@@ -1822,6 +2072,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1882,6 +2135,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'pi' && !cliDir) {
|
||||
throw new Error(getPiNotFoundMessage());
|
||||
}
|
||||
if (mode === 'deepseek' && !cliDir) {
|
||||
throw new Error(getDeepSeekNotFoundMessage());
|
||||
}
|
||||
if (mode === 'grok' && !cliDir) {
|
||||
throw new Error(getGrokNotFoundMessage());
|
||||
}
|
||||
if (mode === 'omp' && !cliDir) {
|
||||
throw new Error(getOmpNotFoundMessage());
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
@@ -1896,6 +2158,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
sessionName: name,
|
||||
@@ -1926,7 +2191,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
||||
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
||||
// pane cd's into workingDir below via respawn-pane.
|
||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||
// tmux <3.7 allocates history only at pane creation, so its global default
|
||||
// must be set immediately BEFORE new-session. tmux 3.7+ can resize a pane
|
||||
// after creation; target only the new session there because changing the
|
||||
// global option can resize (and when lowered, trim) unrelated live panes.
|
||||
const safeHistoryLimit =
|
||||
Number.isSafeInteger(historyLimit) && historyLimit > 0 ? Math.trunc(historyLimit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
const createSessionCommand = this.supportsLiveHistoryResize()
|
||||
? `${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`
|
||||
: `${this.tmux()} set-option -g history-limit ${safeHistoryLimit} \\; new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`;
|
||||
execSync(createSessionCommand, {
|
||||
cwd: TMUX_LAUNCH_CWD,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -1955,6 +2229,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'gemini') {
|
||||
this._configureGemini(muxName);
|
||||
}
|
||||
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
|
||||
if (mode === 'deepseek') {
|
||||
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
|
||||
}
|
||||
|
||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||
@@ -1991,16 +2269,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
.catch(() => {
|
||||
/* Already set globally as fallback */
|
||||
}),
|
||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
|
||||
// in constants.js), which stays lower to protect browser/mobile memory.
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — falls back to tmux default */
|
||||
}),
|
||||
];
|
||||
|
||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||
@@ -2121,10 +2389,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
name,
|
||||
@@ -2135,16 +2405,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||
|
||||
// Re-apply the configured tmux history-limit after respawn (kept in sync
|
||||
// with the live setting via setHistoryLimit()).
|
||||
if (!IS_TEST_MODE) {
|
||||
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).catch(() => {
|
||||
/* Non-critical — keeps existing tmux history-limit */
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
const { pathExport } = this.buildPathExport(mode);
|
||||
|
||||
@@ -2161,6 +2421,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
sessionName: name,
|
||||
@@ -2185,6 +2448,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'gemini') {
|
||||
this._configureGemini(muxName);
|
||||
}
|
||||
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
|
||||
if (mode === 'deepseek') {
|
||||
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
|
||||
}
|
||||
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
@@ -3003,9 +3270,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
|
||||
* changes the terminal-history setting). Invalid limits fall back to the
|
||||
* default. Best-effort per session.
|
||||
* Apply a tmux history limit. tmux 3.7+ safely targets tracked live sessions;
|
||||
* older releases can only change the global default for future panes. Invalid
|
||||
* limits fall back to the default.
|
||||
*/
|
||||
async setHistoryLimit(limit: number): Promise<void> {
|
||||
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
@@ -3014,12 +3281,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
);
|
||||
await Promise.allSettled(updates);
|
||||
if (this.supportsLiveHistoryResize()) {
|
||||
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
);
|
||||
await Promise.allSettled(updates);
|
||||
return;
|
||||
}
|
||||
|
||||
await execAsync(`${this.tmux()} set-option -g history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).catch(() => {
|
||||
// No tmux server yet is fine: legacy createSession sets the same default
|
||||
// immediately before it creates the first pane.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* @fileoverview Pure ANSI helpers for the TUI preview pane.
|
||||
*
|
||||
* The preview shows the tail of a session's raw terminal stream, which is
|
||||
* xterm-bound bytes: SGR colors, cursor jumps, OSC titles, DECSET modes and
|
||||
* carriage-return repaints. This is NOT a terminal emulator. It reconstructs a
|
||||
* readable, color-preserving tail: SGR survives, everything else that steers a
|
||||
* cursor is dropped, and a `\r` is honored as "back to column 0" so a spinner
|
||||
* that repaints its line 200 times contributes one line instead of 200.
|
||||
*
|
||||
* CURSOR ADDRESSING (`ESC [ r ; c H`) is honored too, and it has to be: an Ink
|
||||
* TUI like Claude Code repaints by ROW and emits almost no newlines, so
|
||||
* dropping those sequences collapses a whole screen into one unreadable line
|
||||
* (measured against a live pane, 2026-08-16). A jump to column 1 starts a new
|
||||
* display line, a jump within a row moves the write position, which is the same
|
||||
* reading `normalizeCapturedFrame` in `web/approval-inbox.ts` takes of the same
|
||||
* kind of frame.
|
||||
*
|
||||
* Two approximations are deliberate, because the alternative is an emulator:
|
||||
* a carriage-return overwrite counts CODE POINTS, not display columns (so a
|
||||
* repaint over CJK text can land one cell off), and tab stops are counted the
|
||||
* same way. Neither can corrupt output, they only shift a repaint's alignment.
|
||||
* Absolute ROW numbers are ignored as well: rows arrive in the order they are
|
||||
* painted, which for a tail is the order worth reading.
|
||||
*
|
||||
* @module tui/tui-ansi
|
||||
*/
|
||||
|
||||
const ESC = 0x1b;
|
||||
const BEL = 0x07;
|
||||
const ST_C1 = 0x9c;
|
||||
const DEL = 0x7f;
|
||||
|
||||
/** SGR reset, appended by `clipStyledLine` so a clipped line cannot bleed. */
|
||||
export const SGR_RESET = '\x1b[0m';
|
||||
|
||||
const TAB_WIDTH = 8;
|
||||
/** Cap on remembered SGR sequences per cell, so a pathological stream cannot grow one unboundedly. */
|
||||
const MAX_ACTIVE_SGR = 32;
|
||||
/** Ceiling on a display line's cells: a stream may address column 99999, a terminal has none. */
|
||||
const MAX_LINE_CELLS = 1000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Escape-sequence scanning
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EscapeScan {
|
||||
/** Index just past the sequence; `text.length` for a truncated one. */
|
||||
next: number;
|
||||
/** The sequence itself, only when it is SGR (`CSI ... m`) and therefore kept. */
|
||||
sgr?: string;
|
||||
/** 1-based column of a cursor-position sequence (`CSI r ; c H` or `f`). */
|
||||
column?: number;
|
||||
/** 1-based row of that same sequence. Row 1 means a repaint is starting. */
|
||||
row?: number;
|
||||
}
|
||||
|
||||
/** The row and column a `CSI r ; c H` addresses. Both parameters default to 1. */
|
||||
function cursorPosition(params: string): { row: number; column: number } {
|
||||
const parts = params.split(';');
|
||||
const read = (index: number): number => {
|
||||
const value = Number.parseInt(parts[index] ?? '', 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : 1;
|
||||
};
|
||||
return { row: read(0), column: read(1) };
|
||||
}
|
||||
|
||||
/** Scan a CSI body starting at `from` (params, then intermediates, then a final byte). */
|
||||
function readCsi(text: string, start: number, from: number, keepSgr: boolean): EscapeScan {
|
||||
let j = from;
|
||||
while (j < text.length && text.charCodeAt(j) >= 0x30 && text.charCodeAt(j) <= 0x3f) j++;
|
||||
while (j < text.length && text.charCodeAt(j) >= 0x20 && text.charCodeAt(j) <= 0x2f) j++;
|
||||
if (j >= text.length) return { next: text.length };
|
||||
const next = j + 1;
|
||||
if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) };
|
||||
if (keepSgr && (text[j] === 'H' || text[j] === 'f')) {
|
||||
return { next, ...cursorPosition(text.slice(from, j)) };
|
||||
}
|
||||
return { next };
|
||||
}
|
||||
|
||||
/** Scan an OSC/DCS/PM/APC body: everything up to BEL, C1 ST or `ESC \`. */
|
||||
function readStringSequence(text: string, from: number): number {
|
||||
let j = from;
|
||||
while (j < text.length) {
|
||||
const code = text.charCodeAt(j);
|
||||
if (code === BEL || code === ST_C1) return j + 1;
|
||||
if (code === ESC && text[j + 1] === '\\') return j + 2;
|
||||
j++;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/** Scan the escape sequence starting at `i` (which must be an ESC). */
|
||||
function readEscape(text: string, i: number): EscapeScan {
|
||||
const second = text[i + 1];
|
||||
if (second === undefined) return { next: text.length };
|
||||
if (second === '[') return readCsi(text, i, i + 2, true);
|
||||
if (second === ']' || second === 'P' || second === 'X' || second === '^' || second === '_') {
|
||||
return { next: readStringSequence(text, i + 2) };
|
||||
}
|
||||
// Charset / character-set selection: one more byte belongs to the sequence.
|
||||
if (second === '(' || second === ')' || second === '*' || second === '+' || second === '#' || second === '%') {
|
||||
return { next: Math.min(text.length, i + 3) };
|
||||
}
|
||||
return { next: i + 2 };
|
||||
}
|
||||
|
||||
/** Scan a single-byte C1 control at `i` (0x80-0x9f). */
|
||||
function readC1(text: string, i: number): number {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === 0x9b) return readCsi(text, i, i + 1, false).next;
|
||||
if (code === 0x90 || code === 0x9d || code === 0x9e || code === 0x9f) return readStringSequence(text, i + 1);
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
function isC1(code: number): boolean {
|
||||
return code >= 0x80 && code <= 0x9f;
|
||||
}
|
||||
|
||||
/** `CSI 0 m`, `CSI m` and `CSI 0;0 m` all mean "back to plain". */
|
||||
function isSgrReset(seq: string): boolean {
|
||||
const params = seq.slice(2, -1);
|
||||
return params === '' || /^0(?:;0)*$/.test(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one SGR sequence into the active set. Sequences accumulate in arrival
|
||||
* order (a later color simply wins when replayed), a reset clears them, and a
|
||||
* repeat moves rather than duplicates.
|
||||
*/
|
||||
function applySgr(active: string[], seq: string): string[] {
|
||||
if (isSgrReset(seq)) return [];
|
||||
const next = active.filter((s) => s !== seq);
|
||||
next.push(seq);
|
||||
return next.length > MAX_ACTIVE_SGR ? next.slice(-MAX_ACTIVE_SGR) : next;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Display width
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Combining marks, variation selectors and other zero-advance code points.
|
||||
* Pragmatic, not exhaustive: enough that accents and emoji modifiers do not
|
||||
* inflate a measured width.
|
||||
*/
|
||||
const ZERO_WIDTH_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x0300, 0x036f],
|
||||
[0x0483, 0x0489],
|
||||
[0x0591, 0x05bd],
|
||||
[0x05bf, 0x05bf],
|
||||
[0x0610, 0x061a],
|
||||
[0x064b, 0x065f],
|
||||
[0x0670, 0x0670],
|
||||
[0x06d6, 0x06dc],
|
||||
[0x0e31, 0x0e31],
|
||||
[0x0e34, 0x0e3a],
|
||||
[0x0e47, 0x0e4e],
|
||||
[0x200b, 0x200f],
|
||||
[0x2028, 0x202e],
|
||||
[0x2060, 0x2064],
|
||||
[0x20d0, 0x20f0],
|
||||
[0xfe00, 0xfe0f],
|
||||
[0xfe20, 0xfe2f],
|
||||
[0xfeff, 0xfeff],
|
||||
];
|
||||
|
||||
/**
|
||||
* East Asian Wide + Fullwidth, plus the standalone code points UAX #11 marks
|
||||
* Wide because they are emoji-presentation by default. This repo ships a zh-CN
|
||||
* locale, so CJK correctness is the point; exhaustive Unicode is not required,
|
||||
* but the scattered BMP entries below are not optional either: `✋` (U+270B) is
|
||||
* one of them and it is a glyph this TUI draws in every waiting row, so getting
|
||||
* it wrong mis-pads a column on every frame.
|
||||
*/
|
||||
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x1100, 0x115f],
|
||||
[0x231a, 0x231b],
|
||||
[0x23e9, 0x23ec],
|
||||
[0x23f0, 0x23f0],
|
||||
[0x23f3, 0x23f3],
|
||||
[0x25fd, 0x25fe],
|
||||
[0x2614, 0x2615],
|
||||
[0x2648, 0x2653],
|
||||
[0x267f, 0x267f],
|
||||
[0x2693, 0x2693],
|
||||
[0x26a1, 0x26a1],
|
||||
[0x26aa, 0x26ab],
|
||||
[0x26bd, 0x26be],
|
||||
[0x26c4, 0x26c5],
|
||||
[0x26ce, 0x26ce],
|
||||
[0x26d4, 0x26d4],
|
||||
[0x26ea, 0x26ea],
|
||||
[0x26f2, 0x26f3],
|
||||
[0x26f5, 0x26f5],
|
||||
[0x26fa, 0x26fa],
|
||||
[0x26fd, 0x26fd],
|
||||
[0x2705, 0x2705],
|
||||
[0x270a, 0x270b],
|
||||
[0x2728, 0x2728],
|
||||
[0x274c, 0x274c],
|
||||
[0x274e, 0x274e],
|
||||
[0x2753, 0x2755],
|
||||
[0x2757, 0x2757],
|
||||
[0x2795, 0x2797],
|
||||
[0x27b0, 0x27b0],
|
||||
[0x27bf, 0x27bf],
|
||||
[0x2b1b, 0x2b1c],
|
||||
[0x2b50, 0x2b50],
|
||||
[0x2b55, 0x2b55],
|
||||
[0x2e80, 0x303e],
|
||||
[0x3041, 0x33ff],
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
[0xa000, 0xa4cf],
|
||||
[0xa960, 0xa97f],
|
||||
[0xac00, 0xd7a3],
|
||||
[0xf900, 0xfaff],
|
||||
[0xfe10, 0xfe19],
|
||||
[0xfe30, 0xfe6f],
|
||||
[0xff00, 0xff60],
|
||||
[0xffe0, 0xffe6],
|
||||
[0x1f004, 0x1f004],
|
||||
[0x1f0cf, 0x1f0cf],
|
||||
[0x1f18e, 0x1f18e],
|
||||
[0x1f191, 0x1f19a],
|
||||
[0x1f200, 0x1f320],
|
||||
[0x1f32d, 0x1f335],
|
||||
[0x1f337, 0x1f37c],
|
||||
[0x1f37e, 0x1f393],
|
||||
[0x1f3a0, 0x1f3ca],
|
||||
[0x1f3cf, 0x1f3d3],
|
||||
[0x1f3e0, 0x1f3f0],
|
||||
[0x1f3f4, 0x1f3f4],
|
||||
[0x1f3f8, 0x1f43e],
|
||||
[0x1f440, 0x1f440],
|
||||
[0x1f442, 0x1f4fc],
|
||||
[0x1f4ff, 0x1f53d],
|
||||
[0x1f54b, 0x1f54e],
|
||||
[0x1f550, 0x1f567],
|
||||
[0x1f57a, 0x1f57a],
|
||||
[0x1f595, 0x1f596],
|
||||
[0x1f5a4, 0x1f5a4],
|
||||
[0x1f5fb, 0x1f64f],
|
||||
[0x1f680, 0x1f6c5],
|
||||
[0x1f6cc, 0x1f6cc],
|
||||
[0x1f6d0, 0x1f6d2],
|
||||
[0x1f6eb, 0x1f6ec],
|
||||
[0x1f6f4, 0x1f6fc],
|
||||
[0x1f7e0, 0x1f7eb],
|
||||
[0x1f90c, 0x1f93a],
|
||||
[0x1f93c, 0x1f945],
|
||||
[0x1f947, 0x1f9ff],
|
||||
[0x1fa70, 0x1faff],
|
||||
[0x20000, 0x2fffd],
|
||||
[0x30000, 0x3fffd],
|
||||
];
|
||||
|
||||
function inRanges(cp: number, ranges: ReadonlyArray<readonly [number, number]>): boolean {
|
||||
for (const [lo, hi] of ranges) {
|
||||
if (cp < lo) return false;
|
||||
if (cp <= hi) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Columns one code point advances the cursor by: 0, 1 or 2. */
|
||||
export function charWidth(codePoint: number): number {
|
||||
if (codePoint < 0x20 || (codePoint >= DEL && codePoint <= 0x9f)) return 0;
|
||||
if (inRanges(codePoint, ZERO_WIDTH_RANGES)) return 0;
|
||||
if (inRanges(codePoint, WIDE_RANGES)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Display width of a string: escape sequences take no columns, CJK takes two. */
|
||||
export function visibleWidth(text: string): number {
|
||||
let width = 0;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
i = readEscape(text, i).next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(text, i);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = text.codePointAt(i) as number;
|
||||
i += cp > 0xffff ? 2 : 1;
|
||||
width += charWidth(cp);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Raw stream to display lines
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One printed code point (plus any combining marks) and the SGR state under it. */
|
||||
interface Cell {
|
||||
text: string;
|
||||
sgr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay cells into a string, emitting an SGR change only where the state
|
||||
* actually changes and closing the line so it is self-contained.
|
||||
*/
|
||||
function renderCells(cells: Cell[]): string {
|
||||
let out = '';
|
||||
let active = '';
|
||||
for (const cell of cells) {
|
||||
if (cell.sgr !== active) {
|
||||
if (active !== '') out += SGR_RESET;
|
||||
out += cell.sgr;
|
||||
active = cell.sgr;
|
||||
}
|
||||
out += cell.text;
|
||||
}
|
||||
if (active !== '') out += SGR_RESET;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw terminal stream into display lines: SGR preserved, every other
|
||||
* escape sequence dropped, `\r` treated as a return to column 0 (the following
|
||||
* text overwrites what is there), tabs expanded, other control characters
|
||||
* dropped.
|
||||
*
|
||||
* Splitting matches `String.split('\n')`, so `''` yields `['']` and a trailing
|
||||
* newline yields a trailing empty line.
|
||||
*/
|
||||
/**
|
||||
* Glyphs a CLI draws as chrome that a plain terminal font very often has no
|
||||
* coverage for, and the ASCII that means the same thing.
|
||||
*
|
||||
* ⚠️ This is NOT a substitute for the glyph TIER. The tier answers "can this
|
||||
* terminal do Unicode at all", which is a locale question, and it says yes for
|
||||
* exactly the terminals this table exists for: a beta tester's font rendered
|
||||
* `·`, `─`, `│` and `▶` perfectly while drawing claude's `❯` prompt and its
|
||||
* `⏵⏵` mode marker as empty boxes. Coverage is per-glyph and undetectable from
|
||||
* here, so the rare ones are folded and the common ones are left alone.
|
||||
*
|
||||
* Kept deliberately SHORT. Every entry is a glyph seen rendering as tofu in a
|
||||
* real terminal, not a guess, and each maps to the arrow it already looks like.
|
||||
*/
|
||||
const PREVIEW_GLYPH_FOLD: ReadonlyMap<string, string> = new Map([
|
||||
['\u276F', '>'], // ❯ heavy right-pointing angle quotation mark (claude, starship, zsh prompts)
|
||||
['\u276E', '<'], // ❮
|
||||
['\u23F5', '>'], // ⏵ black medium right-pointing triangle (claude's bypass-permissions marker)
|
||||
['\u23F4', '<'], // ⏴
|
||||
['\u23F6', '^'], // ⏶
|
||||
['\u23F7', 'v'], // ⏷
|
||||
['\u2771', '>'], // ❱
|
||||
['\u2770', '<'], // ❰
|
||||
// claude's own working/done spinner cycles through these, and they are the
|
||||
// same sparse-Dingbats class as `❯`: the animated line is exactly where a
|
||||
// reader looks, so tofu there is the most visible kind.
|
||||
['\u2722', '*'], // ✢
|
||||
['\u2733', '*'], // ✳
|
||||
['\u2217', '*'], // ∗
|
||||
['\u273B', '*'], // ✻
|
||||
['\u273D', '*'], // ✽
|
||||
['\u2734', '*'], // ✴
|
||||
['\u26A0', '!'], // ⚠ Misc Symbols, and emoji-presentation on many terminals
|
||||
]);
|
||||
|
||||
/**
|
||||
* Replace preview glyphs a plain font is likely to draw as an empty box.
|
||||
*
|
||||
* Applied to ANOTHER program's output on its way into the preview pane, never
|
||||
* to the TUI's own chrome, and skipped at the `nerd` tier where the user has
|
||||
* declared a font that can draw anything.
|
||||
*/
|
||||
export function foldPreviewGlyphs(line: string): string {
|
||||
let out = '';
|
||||
for (const char of line) out += PREVIEW_GLYPH_FOLD.get(char) ?? char;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toDisplayLines(raw: string): string[] {
|
||||
const lines: string[] = [];
|
||||
let cells: Cell[] = [];
|
||||
let col = 0;
|
||||
let active: string[] = [];
|
||||
let sgr = '';
|
||||
|
||||
const endLine = (): void => {
|
||||
lines.push(renderCells(cells));
|
||||
cells = [];
|
||||
col = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Park the write position at a column, padding the gap so the cell array
|
||||
* never grows a hole (a hole would crash the replay, and a stream can address
|
||||
* any column it likes).
|
||||
*/
|
||||
const moveTo = (column: number): void => {
|
||||
const target = Math.min(column, MAX_LINE_CELLS);
|
||||
while (cells.length < target) cells.push({ text: ' ', sgr: '' });
|
||||
col = target;
|
||||
};
|
||||
|
||||
const write = (text: string, width: number): void => {
|
||||
if (width === 0) {
|
||||
// A combining mark belongs to the character it follows, never to a cell
|
||||
// of its own: keeping them together is what stops a clip from severing
|
||||
// an accent from its base letter.
|
||||
if (col > 0) cells[col - 1].text += text;
|
||||
return;
|
||||
}
|
||||
cells[col] = { text, sgr };
|
||||
col++;
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
const code = raw.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
const scan = readEscape(raw, i);
|
||||
if (scan.sgr !== undefined) {
|
||||
active = applySgr(active, scan.sgr);
|
||||
sgr = active.join('');
|
||||
} else if (scan.row === 1 && scan.column === 1) {
|
||||
// ⚠️ A HOME is a full-screen app announcing that it is repainting from
|
||||
// the top, and everything already on screen is about to be overwritten
|
||||
// in place. This replay is line-based and cannot overwrite, so the
|
||||
// faithful equivalent is to start over — without it every repaint was
|
||||
// APPENDED, and a claude pane's tail carried fifty stacked copies of
|
||||
// the same frame. The preview then showed the last N lines, which on a
|
||||
// tall terminal spanned two of them (reported from the beta as the
|
||||
// overview showing the session twice).
|
||||
lines.length = 0;
|
||||
cells = [];
|
||||
col = 0;
|
||||
} else if (scan.column !== undefined) {
|
||||
// Column 1 is a fresh row, which is the only thing a repainting TUI
|
||||
// gives us to split lines on.
|
||||
if (scan.column <= 1) endLine();
|
||||
else moveTo(scan.column - 1);
|
||||
}
|
||||
i = scan.next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(raw, i);
|
||||
continue;
|
||||
}
|
||||
if (code === 0x0a) {
|
||||
endLine();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (code === 0x0d) {
|
||||
col = 0;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (code === 0x09) {
|
||||
const stop = TAB_WIDTH - (col % TAB_WIDTH);
|
||||
for (let n = 0; n < stop; n++) write(' ', 1);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = raw.codePointAt(i) as number;
|
||||
const text = String.fromCodePoint(cp);
|
||||
i += text.length;
|
||||
write(text, charWidth(cp));
|
||||
}
|
||||
endLine();
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameter bytes plus final byte of a CSI sequence whose `ESC [` was cut
|
||||
* off. Requires at least one parameter byte, so ordinary text starting with a
|
||||
* letter is never mistaken for one.
|
||||
*/
|
||||
const SEVERED_CSI = /^[0-9;?:<>=]+[A-Za-z]/;
|
||||
|
||||
/**
|
||||
* Drop the remains of an escape sequence a byte-sliced tail begins in the
|
||||
* middle of.
|
||||
*
|
||||
* `GET /api/sessions/:id/terminal?tail=N` cuts the buffer at a byte offset, so
|
||||
* a tail can start inside `ESC [ 12 ; 1 H` and hand the parser `;1H` as text,
|
||||
* which is exactly what it then prints (observed against a live Claude pane).
|
||||
* Only the severed head is dropped, never a whole line.
|
||||
*/
|
||||
export function dropSeveredEscape(raw: string): string {
|
||||
return raw.replace(SEVERED_CSI, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every escape sequence, keeping the visible text. Needed because the
|
||||
* preview carries the session's OWN colors: under NO_COLOR the frame must not
|
||||
* smuggle them back in.
|
||||
*/
|
||||
export function stripStyles(text: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
i = readEscape(text, i).next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(text, i);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = text.codePointAt(i) as number;
|
||||
const size = cp > 0xffff ? 2 : 1;
|
||||
out += text.slice(i, i + size);
|
||||
i += size;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Clipping and padding
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clip a line that carries SGR to `width` display columns, keeping the styling
|
||||
* that is active up to the clip point and closing it with a reset. Never splits
|
||||
* a code point, a combining sequence or an escape sequence, and never emits
|
||||
* half of a double-width character (the cell is dropped instead).
|
||||
*/
|
||||
export function clipStyledLine(line: string, width: number): string {
|
||||
if (width <= 0) return '';
|
||||
let out = '';
|
||||
let used = 0;
|
||||
let active: string[] = [];
|
||||
// Styles are emitted lazily, right before the character that wears them, so a
|
||||
// sequence sitting exactly on the clip boundary is not carried into a line it
|
||||
// no longer styles.
|
||||
let emitted = '';
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const code = line.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
const scan = readEscape(line, i);
|
||||
if (scan.sgr !== undefined) active = applySgr(active, scan.sgr);
|
||||
i = scan.next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(line, i);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = line.codePointAt(i) as number;
|
||||
const w = charWidth(cp);
|
||||
if (used + w > width) break;
|
||||
const style = active.join('');
|
||||
if (style !== emitted) {
|
||||
if (emitted !== '') out += SGR_RESET;
|
||||
out += style;
|
||||
emitted = style;
|
||||
}
|
||||
out += String.fromCodePoint(cp);
|
||||
used += w;
|
||||
i += cp > 0xffff ? 2 : 1;
|
||||
}
|
||||
return emitted !== '' ? out + SGR_RESET : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad or clip to exactly `width` display columns. A clip that lands on a
|
||||
* double-width boundary leaves one column short, so the pad runs after it.
|
||||
*/
|
||||
export function padDisplay(text: string, width: number): string {
|
||||
if (width <= 0) return '';
|
||||
const w = visibleWidth(text);
|
||||
if (w === width) return text;
|
||||
if (w < width) return text + ' '.repeat(width - w);
|
||||
const clipped = clipStyledLine(text, width);
|
||||
return clipped + ' '.repeat(Math.max(0, width - visibleWidth(clipped)));
|
||||
}
|
||||
+2697
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @fileoverview Pure reading of an approvals-inbox item: what the card says,
|
||||
* which keys are live for it, and which of them just appeared.
|
||||
*
|
||||
* This is the half of "answer the dialog from the dashboard" that can be stated
|
||||
* as a function of the item. The IO half (`POST /api/approvals/:id/answer`)
|
||||
* lives in `tui-client.ts`, and the server re-captures the pane before it aims
|
||||
* any keystroke, so a card that went stale is refused rather than mis-answered.
|
||||
*
|
||||
* The key matrix is deliberately narrow, because the alternative is typing a
|
||||
* digit into whatever now has focus:
|
||||
*
|
||||
* | kind | y | n | 1-9 |
|
||||
* | ---------- | ------------ | ---------------------- | ------------------------- |
|
||||
* | permission | approve | the parsed "No" option, | only digits the server |
|
||||
* | question | approve | else Esc | actually parsed off screen |
|
||||
* | idle | not a dialog: `p` (the composer) is the reply path |
|
||||
*
|
||||
* A digit that is not among the parsed options returns null, which is what lets
|
||||
* the caller fall back to the list's own 1-9 jump instead of sending a keystroke
|
||||
* the dialog has no answer for.
|
||||
*
|
||||
* PURE: no IO, no timers, no `process.*`.
|
||||
*
|
||||
* @module tui/tui-approvals
|
||||
*/
|
||||
|
||||
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
|
||||
import type { TuiApprovalAnswer } from './tui-client.js';
|
||||
|
||||
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
|
||||
export type TuiApprovalTone = 'err' | 'warn';
|
||||
|
||||
export interface TuiApprovalCard {
|
||||
tone: TuiApprovalTone;
|
||||
/** One line: what is being asked. */
|
||||
title: string;
|
||||
/** Extra context, one entry per line, already trimmed. May be empty. */
|
||||
detail: string[];
|
||||
/** Numbered choices parsed off the pane, empty when the frame did not parse. */
|
||||
options: ApprovalOption[];
|
||||
/** What the user can press right now, in words. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** Longest single line the card contributes before the renderer clips it. */
|
||||
const MAX_CARD_TEXT = 400;
|
||||
|
||||
function clean(text: string | undefined): string {
|
||||
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
|
||||
}
|
||||
|
||||
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
|
||||
return item.kind === 'idle' ? 'warn' : 'err';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card says. Permission prompts lead with the tool (that is the whole
|
||||
* question), questions lead with their message, and an idle prompt says what it
|
||||
* is, since there is nothing to approve.
|
||||
*/
|
||||
export function approvalCard(item: ApprovalItem): TuiApprovalCard {
|
||||
const options = item.options ?? [];
|
||||
const message = clean(item.message);
|
||||
const summary = clean(item.toolSummary) || clean(item.toolName);
|
||||
|
||||
if (item.kind === 'idle') {
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: message || 'waiting for your reply',
|
||||
detail: [],
|
||||
options: [],
|
||||
hint: 'p to reply',
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
item.kind === 'permission'
|
||||
? `requests: ${summary || 'permission'}`
|
||||
: message || `question: ${summary || 'Claude is asking'}`;
|
||||
const detail: string[] = [];
|
||||
if (item.kind === 'permission' && message && message !== summary) detail.push(message);
|
||||
|
||||
return {
|
||||
tone: 'err',
|
||||
title,
|
||||
detail,
|
||||
options,
|
||||
hint: options.length > 0 ? 'y approve · n deny · digit chooses' : 'y approve · n deny',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed option that means "no". Claude renders it as `3. No, tell Claude
|
||||
* what to do (esc)`, and answering with its digit is the same keystroke the
|
||||
* dialog itself is waiting for; without a parsed one the answer route's `deny`
|
||||
* sends Esc, which every dialog understands.
|
||||
*/
|
||||
export function approvalDenyOption(item: ApprovalItem): number | null {
|
||||
const match = (item.options ?? []).find((option) => /^no\b/i.test(option.label));
|
||||
return match ? match.n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer one key produces, or null when that key means nothing here (so the
|
||||
* caller can let its normal binding through).
|
||||
*/
|
||||
export function approvalAnswerForKey(item: ApprovalItem, key: string): TuiApprovalAnswer | null {
|
||||
// An idle prompt has no dialog on screen: a digit or a `1` would land in the
|
||||
// composer as text. The card points at `p` instead.
|
||||
if (item.kind === 'idle') return null;
|
||||
if (key === 'y') return { action: 'approve' };
|
||||
if (key === 'n') {
|
||||
const deny = approvalDenyOption(item);
|
||||
return deny === null ? { action: 'deny' } : { action: 'option', option: deny };
|
||||
}
|
||||
if (key >= '1' && key <= '9') {
|
||||
const option = Number.parseInt(key, 10);
|
||||
return (item.options ?? []).some((entry) => entry.n === option) ? { action: 'option', option } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids in `items` that `seen` has not recorded. The bell rings for these and for
|
||||
* nothing else, which is what keeps a repaint (or a refetch that returns the
|
||||
* same pending item) silent.
|
||||
*
|
||||
* Answered ids stay in `seen` on purpose: the inbox restores an item under its
|
||||
* ORIGINAL id when a write fails, and re-ringing for a prompt the user already
|
||||
* heard about is worse than missing one.
|
||||
*/
|
||||
export function newApprovalIds(seen: ReadonlySet<string>, items: readonly ApprovalItem[]): string[] {
|
||||
const fresh: string[] = [];
|
||||
for (const item of items) if (!seen.has(item.id) && !fresh.includes(item.id)) fresh.push(item.id);
|
||||
return fresh;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @fileoverview Pure single-line editor behind the TUI's prompt composer (`p`)
|
||||
* and search query (`/`).
|
||||
*
|
||||
* Text is held as CODE POINTS rather than a string, because every operation
|
||||
* here is index-based and a cursor that can land inside a surrogate pair
|
||||
* eventually deletes half an emoji. Combining marks are their own entries: they
|
||||
* are zero-width, so they neither move the cursor's column nor cost a cell, and
|
||||
* backspace peeling one off a base letter is what a terminal editor does.
|
||||
*
|
||||
* Scrolling is derived, never remembered implicitly: `composerScroll()` takes
|
||||
* the width and returns the state whose window holds the cursor, which is what
|
||||
* keeps "what the footer shows" a function of the state plus the terminal width
|
||||
* rather than of the order the user pressed keys in.
|
||||
*
|
||||
* PURE: no IO, no timers, no `process.*`. Enter and Escape are reported as
|
||||
* `submit`/`cancel` rather than acted on, since only the caller knows whether
|
||||
* Enter means "send this prompt" or "open the highlighted search result".
|
||||
*
|
||||
* @module tui/tui-composer
|
||||
*/
|
||||
|
||||
import { charWidth } from './tui-ansi.js';
|
||||
import type { TuiInputEvent } from './tui-keys.js';
|
||||
|
||||
export interface TuiComposerState {
|
||||
/** Code points. `chars.join('')` is the text. */
|
||||
readonly chars: readonly string[];
|
||||
/** 0..chars.length. The cursor sits BEFORE `chars[cursor]`. */
|
||||
readonly cursor: number;
|
||||
/** First visible code point, as `composerScroll()` last resolved it. */
|
||||
readonly scroll: number;
|
||||
}
|
||||
|
||||
export function createComposer(text = ''): TuiComposerState {
|
||||
const chars = [...text];
|
||||
return { chars, cursor: chars.length, scroll: 0 };
|
||||
}
|
||||
|
||||
export function composerText(state: TuiComposerState): string {
|
||||
return state.chars.join('');
|
||||
}
|
||||
|
||||
function withChars(chars: readonly string[], cursor: number, scroll: number): TuiComposerState {
|
||||
const clampedCursor = Math.min(Math.max(0, cursor), chars.length);
|
||||
return { chars, cursor: clampedCursor, scroll: Math.min(Math.max(0, scroll), chars.length) };
|
||||
}
|
||||
|
||||
/** Insert typed text at the cursor. Newlines are stripped: this is one line. */
|
||||
export function composerInsert(state: TuiComposerState, value: string): TuiComposerState {
|
||||
const inserted = [...value.replace(/[\r\n]+/g, ' ')];
|
||||
if (inserted.length === 0) return state;
|
||||
const chars = [...state.chars.slice(0, state.cursor), ...inserted, ...state.chars.slice(state.cursor)];
|
||||
return withChars(chars, state.cursor + inserted.length, state.scroll);
|
||||
}
|
||||
|
||||
/** Delete the code point before the cursor. */
|
||||
export function composerBackspace(state: TuiComposerState): TuiComposerState {
|
||||
if (state.cursor === 0) return state;
|
||||
const chars = [...state.chars.slice(0, state.cursor - 1), ...state.chars.slice(state.cursor)];
|
||||
return withChars(chars, state.cursor - 1, state.scroll);
|
||||
}
|
||||
|
||||
/** Delete the code point under the cursor (the Delete key). */
|
||||
export function composerDelete(state: TuiComposerState): TuiComposerState {
|
||||
if (state.cursor >= state.chars.length) return state;
|
||||
const chars = [...state.chars.slice(0, state.cursor), ...state.chars.slice(state.cursor + 1)];
|
||||
return withChars(chars, state.cursor, state.scroll);
|
||||
}
|
||||
|
||||
/** Delete back to the start of the word before the cursor (Ctrl+W). */
|
||||
export function composerDeleteWord(state: TuiComposerState): TuiComposerState {
|
||||
let start = state.cursor;
|
||||
while (start > 0 && state.chars[start - 1] === ' ') start--;
|
||||
while (start > 0 && state.chars[start - 1] !== ' ') start--;
|
||||
if (start === state.cursor) return state;
|
||||
const chars = [...state.chars.slice(0, start), ...state.chars.slice(state.cursor)];
|
||||
return withChars(chars, start, state.scroll);
|
||||
}
|
||||
|
||||
export function composerMove(state: TuiComposerState, delta: number): TuiComposerState {
|
||||
const cursor = Math.min(Math.max(0, state.cursor + Math.trunc(delta)), state.chars.length);
|
||||
return cursor === state.cursor ? state : withChars(state.chars, cursor, state.scroll);
|
||||
}
|
||||
|
||||
export function composerHome(state: TuiComposerState): TuiComposerState {
|
||||
return state.cursor === 0 ? state : withChars(state.chars, 0, state.scroll);
|
||||
}
|
||||
|
||||
export function composerEnd(state: TuiComposerState): TuiComposerState {
|
||||
return state.cursor === state.chars.length ? state : withChars(state.chars, state.chars.length, state.scroll);
|
||||
}
|
||||
|
||||
export function composerClear(state: TuiComposerState): TuiComposerState {
|
||||
return state.chars.length === 0 ? state : { chars: [], cursor: 0, scroll: 0 };
|
||||
}
|
||||
|
||||
/** Display columns of `chars[from..to)`. */
|
||||
function widthOf(chars: readonly string[], from: number, to: number): number {
|
||||
let width = 0;
|
||||
for (let i = from; i < to; i++) width += charWidth(chars[i].codePointAt(0) ?? 0);
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `scroll` so the cursor is inside a window `width` columns wide,
|
||||
* scrolling the minimum needed. One column is reserved for the cursor itself,
|
||||
* so a cursor at the end of the text still has a cell to sit in instead of
|
||||
* hanging one past the edge where the terminal would wrap it.
|
||||
*/
|
||||
export function composerScroll(state: TuiComposerState, width: number): TuiComposerState {
|
||||
const usable = Math.max(0, Math.trunc(width) - 1);
|
||||
let scroll = Math.min(Math.max(0, state.scroll), state.cursor);
|
||||
while (scroll < state.cursor && widthOf(state.chars, scroll, state.cursor) > usable) scroll++;
|
||||
return scroll === state.scroll ? state : { chars: state.chars, cursor: state.cursor, scroll };
|
||||
}
|
||||
|
||||
export interface TuiComposerWindow {
|
||||
/** The visible slice of the text. */
|
||||
text: string;
|
||||
/** Cursor offset in display columns from the start of `text`. */
|
||||
cursorColumn: number;
|
||||
/** Resolved first visible code point (may differ from `state.scroll`). */
|
||||
scroll: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice the footer draws plus where the terminal cursor belongs. The scroll
|
||||
* is resolved here too, so a renderer that never writes state back still shows
|
||||
* the cursor.
|
||||
*/
|
||||
export function composerWindow(state: TuiComposerState, width: number): TuiComposerWindow {
|
||||
const columns = Math.max(1, Math.trunc(width));
|
||||
const scrolled = composerScroll(state, columns);
|
||||
const { chars, cursor, scroll } = scrolled;
|
||||
let used = 0;
|
||||
let end = scroll;
|
||||
while (end < chars.length) {
|
||||
const next = charWidth(chars[end].codePointAt(0) ?? 0);
|
||||
if (used + next > columns) break;
|
||||
used += next;
|
||||
end++;
|
||||
}
|
||||
return {
|
||||
text: chars.slice(scroll, Math.max(end, cursor)).join(''),
|
||||
cursorColumn: widthOf(chars, scroll, cursor),
|
||||
scroll,
|
||||
};
|
||||
}
|
||||
|
||||
export type TuiComposerStep =
|
||||
| { kind: 'edit'; state: TuiComposerState }
|
||||
| { kind: 'submit'; text: string }
|
||||
| { kind: 'cancel' }
|
||||
| { kind: 'ignore' };
|
||||
|
||||
/**
|
||||
* One keystroke. Enter and Escape are REPORTED rather than applied: `p` sends
|
||||
* the line while `/` opens the highlighted result, and only the caller knows
|
||||
* which.
|
||||
*/
|
||||
export function composerStep(state: TuiComposerState, event: TuiInputEvent): TuiComposerStep {
|
||||
switch (event.type) {
|
||||
case 'char':
|
||||
return { kind: 'edit', state: composerInsert(state, event.value) };
|
||||
case 'backspace':
|
||||
return { kind: 'edit', state: composerBackspace(state) };
|
||||
case 'enter':
|
||||
return { kind: 'submit', text: composerText(state) };
|
||||
case 'escape':
|
||||
return { kind: 'cancel' };
|
||||
case 'key':
|
||||
switch (event.name) {
|
||||
case 'left':
|
||||
return { kind: 'edit', state: composerMove(state, -1) };
|
||||
case 'right':
|
||||
return { kind: 'edit', state: composerMove(state, 1) };
|
||||
case 'home':
|
||||
return { kind: 'edit', state: composerHome(state) };
|
||||
case 'end':
|
||||
return { kind: 'edit', state: composerEnd(state) };
|
||||
case 'delete':
|
||||
return { kind: 'edit', state: composerDelete(state) };
|
||||
default:
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
case 'ctrl':
|
||||
switch (event.key) {
|
||||
case 'c':
|
||||
return { kind: 'cancel' };
|
||||
case 'a':
|
||||
return { kind: 'edit', state: composerHome(state) };
|
||||
case 'e':
|
||||
return { kind: 'edit', state: composerEnd(state) };
|
||||
case 'u':
|
||||
return { kind: 'edit', state: composerClear(state) };
|
||||
case 'w':
|
||||
return { kind: 'edit', state: composerDeleteWord(state) };
|
||||
default:
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
default:
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @fileoverview Pure formatting of `GET /api/away-digest` into the lines the
|
||||
* `g` overlay scrolls.
|
||||
*
|
||||
* The digest answers "what happened while I was away", so it is read top-down
|
||||
* and never studied: every entry is one line (age, session, what happened), a
|
||||
* long section is capped with a "… n more" tail rather than allowed to push the
|
||||
* next section off screen, and the counts that matter live in the first line
|
||||
* where they are visible without scrolling at all.
|
||||
*
|
||||
* PURE: no IO, no clock of its own (the caller passes `now`), no `process.*`.
|
||||
*
|
||||
* @module tui/tui-digest
|
||||
*/
|
||||
|
||||
import { formatElapsed, formatTokens } from './tui-render.js';
|
||||
import type { AwayDigestItem, AwayDigestResponse, AwayDigestSectionName } from '../web/away-digest.js';
|
||||
|
||||
/** Entries per section before the tail takes over. */
|
||||
export const DIGEST_SECTION_LIMIT = 6;
|
||||
|
||||
const SECTION_ORDER: ReadonlyArray<readonly [AwayDigestSectionName, string]> = [
|
||||
['needsAttention', 'NEEDS ATTENTION'],
|
||||
['completed', 'COMPLETED'],
|
||||
['stillRunning', 'STILL RUNNING'],
|
||||
['idle', 'IDLE'],
|
||||
['informational', 'INFO'],
|
||||
];
|
||||
|
||||
const RANGE_WORDS: Record<string, string> = {
|
||||
'since-last-visit': 'since your last visit',
|
||||
'1h': 'the last hour',
|
||||
today: 'today',
|
||||
'24h': 'the last 24 hours',
|
||||
custom: 'the selected window',
|
||||
};
|
||||
|
||||
export interface TuiDigestOptions {
|
||||
now: number;
|
||||
sectionLimit?: number;
|
||||
}
|
||||
|
||||
function ageColumn(item: AwayDigestItem, now: number): string {
|
||||
const age = item.timestamp > 0 ? formatElapsed(now - item.timestamp) : '';
|
||||
return age.padEnd(4);
|
||||
}
|
||||
|
||||
function itemLine(item: AwayDigestItem, now: number): string {
|
||||
const who = item.sessionName ?? item.sessionId?.slice(0, 8) ?? '';
|
||||
const what = [item.title, item.detail].filter((part) => part && part.trim() !== '').join(' · ');
|
||||
return ` ${ageColumn(item, now)} ${[who, what].filter((part) => part !== '').join(' ')}`.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The digest as display lines. The first line is the summary, then one block
|
||||
* per non-empty section, then the token totals when the range had any.
|
||||
*/
|
||||
export function formatAwayDigest(digest: AwayDigestResponse, options: TuiDigestOptions): string[] {
|
||||
const limit = Math.max(1, Math.trunc(options.sectionLimit ?? DIGEST_SECTION_LIMIT));
|
||||
const { totals } = digest;
|
||||
const lines: string[] = [
|
||||
[
|
||||
RANGE_WORDS[digest.range.range] ?? 'recently',
|
||||
`${totals.sessionsCreated} started`,
|
||||
`${totals.sessionsExited} exited`,
|
||||
`${totals.activeSessions} running`,
|
||||
].join(' · '),
|
||||
];
|
||||
|
||||
let entries = 0;
|
||||
for (const [key, label] of SECTION_ORDER) {
|
||||
const items = digest.sections[key] ?? [];
|
||||
if (items.length === 0) continue;
|
||||
entries += items.length;
|
||||
lines.push('', `${label} (${items.length})`);
|
||||
for (const item of items.slice(0, limit)) lines.push(itemLine(item, options.now));
|
||||
if (items.length > limit) lines.push(` … ${items.length - limit} more`);
|
||||
}
|
||||
|
||||
if (entries === 0) lines.push('', 'nothing happened while you were away');
|
||||
|
||||
const tokens = [
|
||||
formatTokens(totals.inputTokens ?? 0) ? `${formatTokens(totals.inputTokens ?? 0)} in` : '',
|
||||
formatTokens(totals.outputTokens ?? 0) ? `${formatTokens(totals.outputTokens ?? 0)} out` : '',
|
||||
typeof totals.estimatedCost === 'number' && totals.estimatedCost > 0 ? `$${totals.estimatedCost.toFixed(2)}` : '',
|
||||
].filter((part) => part !== '');
|
||||
if (tokens.length > 0) lines.push('', `tokens: ${tokens.join(' · ')}`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @fileoverview Pure byte-stream to input-event parser for raw-mode stdin.
|
||||
*
|
||||
* Stateful (a sequence can arrive split across reads, and a UTF-8 character can
|
||||
* be split mid-code-point) but pure: it owns a byte buffer and nothing else, no
|
||||
* stdin, no timers. The one timing decision a terminal forces on us stays with
|
||||
* the caller: a lone ESC is indistinguishable from the start of an arrow key
|
||||
* until something either follows it or does not, so the parser HOLDS a trailing
|
||||
* ESC and the caller calls `flush()` after ~30ms of silence to turn it into an
|
||||
* Escape event.
|
||||
*
|
||||
* Unknown sequences are swallowed rather than leaked as text: a stray
|
||||
* `CSI 200~` must never end up typed into a prompt composer.
|
||||
*
|
||||
* @module tui/tui-keys
|
||||
*/
|
||||
|
||||
/** Keys with a name rather than a character. */
|
||||
export type TuiNamedKey =
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'home'
|
||||
| 'end'
|
||||
| 'pageup'
|
||||
| 'pagedown'
|
||||
| 'delete'
|
||||
| 'insert';
|
||||
|
||||
export type TuiMouseKind = 'press' | 'release' | 'wheel-up' | 'wheel-down';
|
||||
|
||||
/** Discriminated union, exhaustive-switch friendly (see `utils/assertNever`). */
|
||||
export type TuiInputEvent =
|
||||
| { type: 'char'; value: string }
|
||||
| { type: 'enter' }
|
||||
| { type: 'tab' }
|
||||
| { type: 'backspace' }
|
||||
| { type: 'escape' }
|
||||
| { type: 'ctrl'; key: string }
|
||||
| { type: 'alt'; value: string }
|
||||
| { type: 'key'; name: TuiNamedKey }
|
||||
| { type: 'mouse'; kind: TuiMouseKind; x: number; y: number; button: number };
|
||||
|
||||
export interface TuiKeyParser {
|
||||
/** Decode a chunk. Incomplete tails are held for the next call. */
|
||||
feed(chunk: Buffer | string): TuiInputEvent[];
|
||||
/** Resolve a held ESC (the caller's disambiguation timer fired). */
|
||||
flush(): TuiInputEvent[];
|
||||
/** Bytes currently held back. Exposed for the ESC timer and for tests. */
|
||||
pending(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An unterminated sequence longer than this is not a sequence: the held bytes
|
||||
* are dropped whole, so a garbage burst can neither wedge the parser nor leak
|
||||
* its bytes into a prompt as typed characters.
|
||||
*/
|
||||
const MAX_PENDING_BYTES = 64;
|
||||
|
||||
/** Bytes in a UTF-8 sequence given its lead byte; 0 for a byte that cannot lead one. */
|
||||
function utf8SequenceLength(lead: number): number {
|
||||
if (lead < 0x80) return 1;
|
||||
if (lead >= 0xc2 && lead <= 0xdf) return 2;
|
||||
if (lead >= 0xe0 && lead <= 0xef) return 3;
|
||||
if (lead >= 0xf0 && lead <= 0xf4) return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const CSI_FINAL_KEYS: Record<string, TuiNamedKey> = {
|
||||
A: 'up',
|
||||
B: 'down',
|
||||
C: 'right',
|
||||
D: 'left',
|
||||
H: 'home',
|
||||
F: 'end',
|
||||
};
|
||||
|
||||
/** `CSI <n> ~` keys, by their first numeric parameter. */
|
||||
const CSI_TILDE_KEYS: Record<number, TuiNamedKey> = {
|
||||
1: 'home',
|
||||
2: 'insert',
|
||||
3: 'delete',
|
||||
4: 'end',
|
||||
5: 'pageup',
|
||||
6: 'pagedown',
|
||||
7: 'home',
|
||||
8: 'end',
|
||||
};
|
||||
|
||||
/** Result of trying to parse one sequence off the front of the buffer. */
|
||||
type ParseStep = { consumed: number; events: TuiInputEvent[] } | 'incomplete';
|
||||
|
||||
const NOTHING: TuiInputEvent[] = [];
|
||||
|
||||
export function createKeyParser(): TuiKeyParser {
|
||||
let buf: Buffer = Buffer.alloc(0);
|
||||
|
||||
/** Parse the CSI/SS3 sequence that starts at buf[0] === ESC. */
|
||||
const parseEscape = (): ParseStep => {
|
||||
if (buf.length < 2) return 'incomplete';
|
||||
const second = buf[1];
|
||||
|
||||
// SS3 (`ESC O <final>`): the arrows/Home/End of application-cursor mode.
|
||||
if (second === 0x4f) {
|
||||
if (buf.length < 3) return 'incomplete';
|
||||
const name = CSI_FINAL_KEYS[String.fromCharCode(buf[2])];
|
||||
return { consumed: 3, events: name ? [{ type: 'key', name }] : NOTHING };
|
||||
}
|
||||
|
||||
// ESC followed by a printable character IN THE SAME READ is Alt+that key:
|
||||
// that is how every terminal sends a meta chord. A lone Esc cannot look
|
||||
// like this, because a buffer holding only ESC returns 'incomplete' above
|
||||
// and is flushed as `escape` when the read ends, which is the standard way
|
||||
// to tell the two apart without a timer.
|
||||
//
|
||||
// ⚠️ Three characters are deliberately NOT treated as Alt chords, because
|
||||
// the terminal uses them to introduce sequences and a chord is
|
||||
// indistinguishable from one: `[` (CSI) and `O` (SS3) would swallow every
|
||||
// arrow key, and `]` (OSC) would swallow a terminal's colour-query reply.
|
||||
// Alt+[ and Alt+] therefore cannot exist in a terminal at all, which is why
|
||||
// the list binds bare `[` and `]` for the same job.
|
||||
if (second !== 0x5b) {
|
||||
if (second >= 0x20 && second <= 0x7e && second !== 0x4f && second !== 0x5d) {
|
||||
return { consumed: 2, events: [{ type: 'alt', value: String.fromCharCode(second) }] };
|
||||
}
|
||||
return { consumed: 1, events: [{ type: 'escape' }] };
|
||||
}
|
||||
|
||||
let j = 2;
|
||||
while (j < buf.length && buf[j] >= 0x30 && buf[j] <= 0x3f) j++;
|
||||
while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x2f) j++;
|
||||
if (j >= buf.length) return 'incomplete';
|
||||
const final = String.fromCharCode(buf[j]);
|
||||
const params = buf.subarray(2, j).toString('latin1');
|
||||
const consumed = j + 1;
|
||||
|
||||
// X10 mouse (`CSI M` + 3 raw bytes): swallowed, but its payload bytes must
|
||||
// be consumed or they would surface as typed characters.
|
||||
if (params === '' && final === 'M') {
|
||||
if (buf.length < consumed + 3) return 'incomplete';
|
||||
return { consumed: consumed + 3, events: NOTHING };
|
||||
}
|
||||
|
||||
if (params.startsWith('<') && (final === 'M' || final === 'm')) {
|
||||
return { consumed, events: parseSgrMouse(params.slice(1), final) };
|
||||
}
|
||||
|
||||
if (final === '~') {
|
||||
const name = CSI_TILDE_KEYS[Number.parseInt(params, 10)];
|
||||
return { consumed, events: name ? [{ type: 'key', name }] : NOTHING };
|
||||
}
|
||||
|
||||
// Modified arrows (`CSI 1;5A`) carry the same final byte; the modifier is
|
||||
// dropped rather than exposed, since nothing in the keymap wants it yet.
|
||||
const named = CSI_FINAL_KEYS[final];
|
||||
return { consumed, events: named ? [{ type: 'key', name: named }] : NOTHING };
|
||||
};
|
||||
|
||||
const parseSgrMouse = (params: string, final: string): TuiInputEvent[] => {
|
||||
const parts = params.split(';');
|
||||
if (parts.length < 3) return NOTHING;
|
||||
const button = Number.parseInt(parts[0], 10);
|
||||
const x = Number.parseInt(parts[1], 10);
|
||||
const y = Number.parseInt(parts[2], 10);
|
||||
if (!Number.isFinite(button) || !Number.isFinite(x) || !Number.isFinite(y)) return NOTHING;
|
||||
if (button >= 64) {
|
||||
// 64 = wheel up, 65 = wheel down (the low bit is the direction).
|
||||
const kind: TuiMouseKind = (button & 1) === 1 ? 'wheel-down' : 'wheel-up';
|
||||
return [{ type: 'mouse', kind, x, y, button }];
|
||||
}
|
||||
// Motion reports (bit 32) would fire on every pixel of a drag; nothing in
|
||||
// the keymap consumes them, so they are swallowed here rather than upstream.
|
||||
if ((button & 32) === 32) return NOTHING;
|
||||
return [{ type: 'mouse', kind: final === 'M' ? 'press' : 'release', x, y, button }];
|
||||
};
|
||||
|
||||
/** Parse one non-escape byte (or one UTF-8 character) off the front. */
|
||||
const parseByte = (): ParseStep => {
|
||||
const b = buf[0];
|
||||
// LF counts as Enter because some terminals send it for Return; the cost is
|
||||
// that Ctrl+J is not bindable, which no key in the plan's keymap wants.
|
||||
if (b === 0x0d || b === 0x0a) return { consumed: 1, events: [{ type: 'enter' }] };
|
||||
if (b === 0x09) return { consumed: 1, events: [{ type: 'tab' }] };
|
||||
if (b === 0x7f || b === 0x08) return { consumed: 1, events: [{ type: 'backspace' }] };
|
||||
if (b === 0x00) return { consumed: 1, events: [{ type: 'ctrl', key: '@' }] };
|
||||
if (b >= 0x01 && b <= 0x1a) {
|
||||
return { consumed: 1, events: [{ type: 'ctrl', key: String.fromCharCode(b + 0x60) }] };
|
||||
}
|
||||
if (b >= 0x1c && b <= 0x1f) {
|
||||
return { consumed: 1, events: [{ type: 'ctrl', key: String.fromCharCode(b + 0x40) }] };
|
||||
}
|
||||
const length = utf8SequenceLength(b);
|
||||
if (length === 0) return { consumed: 1, events: NOTHING };
|
||||
if (buf.length < length) return 'incomplete';
|
||||
const value = buf.subarray(0, length).toString('utf8');
|
||||
// A lead byte followed by junk decodes to U+FFFD; that is corruption on the
|
||||
// wire, not something to type into a composer. Only the bad lead byte is
|
||||
// dropped, so whatever valid input followed it still decodes.
|
||||
if (value.includes('�')) return { consumed: 1, events: NOTHING };
|
||||
return { consumed: length, events: [{ type: 'char', value }] };
|
||||
};
|
||||
|
||||
/** Drain the buffer, stopping at the first incomplete sequence. */
|
||||
const drain = (events: TuiInputEvent[]): void => {
|
||||
while (buf.length > 0) {
|
||||
const step = buf[0] === 0x1b ? parseEscape() : parseByte();
|
||||
if (step === 'incomplete') {
|
||||
if (buf.length > MAX_PENDING_BYTES) buf = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
for (const event of step.events) events.push(event);
|
||||
buf = buf.subarray(step.consumed);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
feed(chunk: Buffer | string): TuiInputEvent[] {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
||||
buf = buf.length === 0 ? Buffer.from(bytes) : Buffer.concat([buf, bytes]);
|
||||
const events: TuiInputEvent[] = [];
|
||||
drain(events);
|
||||
return events;
|
||||
},
|
||||
|
||||
flush(): TuiInputEvent[] {
|
||||
const events: TuiInputEvent[] = [];
|
||||
if (buf.length > 0 && buf[0] === 0x1b) {
|
||||
events.push({ type: 'escape' });
|
||||
buf = buf.subarray(1);
|
||||
drain(events);
|
||||
}
|
||||
return events;
|
||||
},
|
||||
|
||||
pending(): number {
|
||||
return buf.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @fileoverview Pure responsive layout math for the TUI frame.
|
||||
*
|
||||
* One rule decides the shape: below 72 columns (Termius, iPhone portrait) the
|
||||
* preview pane is gone and rows take two lines, which is the constraint the
|
||||
* `sc` chooser was built around and the reason it is still usable on a phone.
|
||||
* Above it, a clamped sidebar carries the session list and the preview takes
|
||||
* the rest.
|
||||
*
|
||||
* Rectangles are 1-based (row 1, column 1 is the top-left cell) because that is
|
||||
* what `ESC [ <row>;<col> H` takes, and every region is clamped to a
|
||||
* non-negative size so a 5x5 terminal degrades instead of producing negative
|
||||
* widths that would crash the renderer.
|
||||
*
|
||||
* @module tui/tui-layout
|
||||
*/
|
||||
|
||||
import type { TuiConnectionStatus } from './tui-types.js';
|
||||
|
||||
/** Width at which the preview pane is dropped and rows become two lines. */
|
||||
export const NARROW_BREAKPOINT = 72;
|
||||
/** Sidebar clamp: narrower than this and a session name stops being readable. */
|
||||
export const SIDEBAR_MIN_WIDTH = 34;
|
||||
/** Sidebar clamp: wider than this is wasted on a list of short names. */
|
||||
export const SIDEBAR_MAX_WIDTH = 44;
|
||||
/** A preview thinner than this shows nothing useful, so the layout goes narrow instead. */
|
||||
export const PREVIEW_MIN_WIDTH = 24;
|
||||
/** Share of the width the sidebar aims for between the clamps. */
|
||||
const SIDEBAR_RATIO = 0.36;
|
||||
|
||||
export interface TuiRect {
|
||||
/** 1-based terminal row of the first line. */
|
||||
row: number;
|
||||
/** 1-based terminal column of the first cell. */
|
||||
col: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TuiLayoutOptions {
|
||||
/**
|
||||
* Reserve one line under the header for the connection banner. The caller
|
||||
* decides with `needsBanner(model.connection)`, so layout stays pure math.
|
||||
*/
|
||||
banner?: boolean;
|
||||
}
|
||||
|
||||
export interface TuiLayout {
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** No preview pane, two-line rows. */
|
||||
narrow: boolean;
|
||||
/** Terminal lines one session row occupies. */
|
||||
rowHeight: 1 | 2;
|
||||
header: TuiRect;
|
||||
/** Connection banner, when the caller asked for one and there was room. */
|
||||
banner: TuiRect | null;
|
||||
/** Everything between header and footer, banner included. */
|
||||
body: TuiRect;
|
||||
/** The session list. */
|
||||
list: TuiRect;
|
||||
/** The one-column rule between list and preview; null in narrow mode. */
|
||||
divider: TuiRect | null;
|
||||
/** The preview pane; null in narrow mode. */
|
||||
preview: TuiRect | null;
|
||||
footer: TuiRect;
|
||||
}
|
||||
|
||||
/** Which connection states get a banner line under the header. */
|
||||
export function needsBanner(connection: TuiConnectionStatus): boolean {
|
||||
return connection !== 'connected';
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectangles for one frame at `cols` x `rows`.
|
||||
*
|
||||
* The header always exists; the footer appears from 2 rows up; the body is
|
||||
* whatever is left, which may legitimately be zero lines high.
|
||||
*/
|
||||
export function computeLayout(cols: number, rows: number, options: TuiLayoutOptions = {}): TuiLayout {
|
||||
const width = Math.max(1, Math.floor(cols) || 1);
|
||||
const height = Math.max(1, Math.floor(rows) || 1);
|
||||
|
||||
const headerHeight = 1;
|
||||
const footerHeight = height >= 2 ? 1 : 0;
|
||||
const bodyHeight = Math.max(0, height - headerHeight - footerHeight);
|
||||
const bodyRow = headerHeight + 1;
|
||||
|
||||
const header: TuiRect = { row: 1, col: 1, width, height: headerHeight };
|
||||
const footer: TuiRect = { row: height, col: 1, width, height: footerHeight };
|
||||
const body: TuiRect = { row: bodyRow, col: 1, width, height: bodyHeight };
|
||||
|
||||
const bannerHeight = options.banner === true && bodyHeight > 0 ? 1 : 0;
|
||||
const banner: TuiRect | null = bannerHeight > 0 ? { row: bodyRow, col: 1, width, height: 1 } : null;
|
||||
|
||||
const contentRow = bodyRow + bannerHeight;
|
||||
const contentHeight = Math.max(0, bodyHeight - bannerHeight);
|
||||
|
||||
const sidebarTarget = Math.floor(width * SIDEBAR_RATIO);
|
||||
const sidebarWidth = clamp(sidebarTarget, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
||||
const previewWidth = width - sidebarWidth - 1;
|
||||
const narrow = width < NARROW_BREAKPOINT || previewWidth < PREVIEW_MIN_WIDTH;
|
||||
|
||||
if (narrow) {
|
||||
return {
|
||||
cols: width,
|
||||
rows: height,
|
||||
narrow: true,
|
||||
rowHeight: 2,
|
||||
header,
|
||||
banner,
|
||||
body,
|
||||
list: { row: contentRow, col: 1, width, height: contentHeight },
|
||||
divider: null,
|
||||
preview: null,
|
||||
footer,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cols: width,
|
||||
rows: height,
|
||||
narrow: false,
|
||||
rowHeight: 1,
|
||||
header,
|
||||
banner,
|
||||
body,
|
||||
list: { row: contentRow, col: 1, width: sidebarWidth, height: contentHeight },
|
||||
divider: { row: contentRow, col: sidebarWidth + 1, width: 1, height: contentHeight },
|
||||
preview: { row: contentRow, col: sidebarWidth + 2, width: previewWidth, height: contentHeight },
|
||||
footer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* @fileoverview Pure state, classification and grouping for the TUI dashboard.
|
||||
*
|
||||
* Classification speaks the web UI's language on purpose (red blocked, yellow
|
||||
* waiting, green working, muted idle), because a user who has both surfaces
|
||||
* open must never have to translate between them. The inputs are the ones the
|
||||
* server already computes: a unified-list row and, when the session is blocked,
|
||||
* the approvals-inbox item that blocks it. Nothing here screen-scrapes.
|
||||
*
|
||||
* Selection is tracked by session id, never by row index: rows re-sort under
|
||||
* the cursor constantly (a session starts working, an approval lands), and an
|
||||
* index-tracked cursor would silently move the selection to a different
|
||||
* session between two keystrokes.
|
||||
*
|
||||
* PURE: no IO, no timers, no `process.*`. The store mutates its own state and
|
||||
* nothing else.
|
||||
*
|
||||
* @module tui/tui-model
|
||||
*/
|
||||
|
||||
import type { SearchResultGroup, SearchSourceType } from '../types/search.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type {
|
||||
TuiConfirmState,
|
||||
TuiConnectionStatus,
|
||||
TuiDigestState,
|
||||
TuiGroup,
|
||||
TuiGroupKey,
|
||||
TuiHeaderInfo,
|
||||
TuiMessage,
|
||||
TuiPickerState,
|
||||
TuiPreview,
|
||||
TuiPromptState,
|
||||
TuiRenderModel,
|
||||
TuiRow,
|
||||
TuiSearchEntry,
|
||||
TuiSearchState,
|
||||
TuiSessionRow,
|
||||
TuiSessionState,
|
||||
TuiUiMode,
|
||||
} from './tui-types.js';
|
||||
|
||||
/** How many history rows the RECENT group shows before it stops being a dashboard. */
|
||||
export const DEFAULT_RECENT_LIMIT = 8;
|
||||
|
||||
export const GROUP_ORDER: readonly TuiGroupKey[] = ['needs-you', 'working', 'idle', 'recent'];
|
||||
|
||||
export const GROUP_LABELS: Record<TuiGroupKey, string> = {
|
||||
'needs-you': 'NEEDS YOU',
|
||||
working: 'WORKING',
|
||||
idle: 'IDLE',
|
||||
recent: 'RECENT',
|
||||
};
|
||||
|
||||
const STATE_GROUP: Record<TuiSessionState, TuiGroupKey> = {
|
||||
'blocked-question': 'needs-you',
|
||||
'blocked-permission': 'needs-you',
|
||||
waiting: 'needs-you',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
recent: 'recent',
|
||||
};
|
||||
|
||||
/** A row is live when the unified merge saw it in the in-memory session map. */
|
||||
export function isLiveRow(session: TuiSessionRow): boolean {
|
||||
return Array.isArray(session.sources) && session.sources.includes('live');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one row.
|
||||
*
|
||||
* Order matters and mirrors `_mobileOverviewState()` in the web UI: a pending
|
||||
* prompt outranks everything (it is literally blocking the agent), and it
|
||||
* outranks a stale `busy` status because the hook is the newer signal. An
|
||||
* errored session has no state of its own here and joins the waiting tier,
|
||||
* since it is equally something only a human can clear.
|
||||
*/
|
||||
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
||||
if (!isLiveRow(session)) return 'recent';
|
||||
if (approval) {
|
||||
if (approval.kind === 'permission') return 'blocked-permission';
|
||||
if (approval.kind === 'question') return 'blocked-question';
|
||||
return 'waiting';
|
||||
}
|
||||
if (session.status === 'error') return 'waiting';
|
||||
if (session.isWorking === true || session.status === 'busy') return 'working';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms the session entered its current state, which is what the intra-group
|
||||
* ordering sorts on. 0 when nothing usable is known.
|
||||
*
|
||||
* A WORKING pane repaints about once a second, so its `lastActivityAt` is
|
||||
* always "now" and would report every running turn as freshly started; the
|
||||
* turn's own start is the pane's last Enter.
|
||||
*/
|
||||
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
||||
if (approval) return approval.createdAt;
|
||||
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
||||
return session.lastActivityAt ?? session.createdAt ?? 0;
|
||||
}
|
||||
|
||||
/** Classify a batch of rows against the pending approvals, keyed by session id. */
|
||||
export function buildRows(
|
||||
sessions: readonly TuiSessionRow[],
|
||||
approvals: ReadonlyMap<string, ApprovalItem> = new Map()
|
||||
): TuiRow[] {
|
||||
return sessions.map((session) => {
|
||||
const approval = approvals.get(session.sessionId);
|
||||
const state = classifySession(session, approval);
|
||||
const row: TuiRow = {
|
||||
session,
|
||||
state,
|
||||
group: STATE_GROUP[state],
|
||||
since: stateSince(state, session, approval),
|
||||
};
|
||||
if (approval) row.approval = approval;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function compareIds(a: TuiRow, b: TuiRow): number {
|
||||
if (a.session.sessionId < b.session.sessionId) return -1;
|
||||
if (a.session.sessionId > b.session.sessionId) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Longest first: the oldest anchor wins, and an unknown anchor sorts last. */
|
||||
function compareLongestFirst(a: TuiRow, b: TuiRow): number {
|
||||
const left = a.since || Number.MAX_SAFE_INTEGER;
|
||||
const right = b.since || Number.MAX_SAFE_INTEGER;
|
||||
return left !== right ? left - right : compareIds(a, b);
|
||||
}
|
||||
|
||||
/** Newest first: the freshest anchor wins, and an unknown anchor sorts last. */
|
||||
function compareNewestFirst(a: TuiRow, b: TuiRow): number {
|
||||
const left = a.since || 0;
|
||||
const right = b.since || 0;
|
||||
return left !== right ? right - left : compareIds(a, b);
|
||||
}
|
||||
|
||||
export interface GroupOptions {
|
||||
/** RECENT is a tail, not a list: everything past this is dropped. */
|
||||
recentLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split classified rows into the four display groups.
|
||||
*
|
||||
* Always returns all four in display order (empty ones included) so callers
|
||||
* never have to guess the shape; the renderer skips the empty ones.
|
||||
*
|
||||
* NEEDS YOU and WORKING are ordered by how long they have been in that state
|
||||
* (longest first: the thing that has waited longest for you is the thing to
|
||||
* look at). IDLE and RECENT are ordered by recency, newest first.
|
||||
*/
|
||||
export function groupSessions(rows: readonly TuiRow[], options: GroupOptions = {}): TuiGroup[] {
|
||||
const recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT));
|
||||
const buckets: Record<TuiGroupKey, TuiRow[]> = {
|
||||
'needs-you': [],
|
||||
working: [],
|
||||
idle: [],
|
||||
recent: [],
|
||||
};
|
||||
for (const row of rows) buckets[row.group].push(row);
|
||||
|
||||
buckets['needs-you'].sort(compareLongestFirst);
|
||||
buckets.working.sort(compareLongestFirst);
|
||||
buckets.idle.sort(compareNewestFirst);
|
||||
buckets.recent.sort(compareNewestFirst);
|
||||
buckets.recent = buckets.recent.slice(0, recentLimit);
|
||||
|
||||
return GROUP_ORDER.map((key) => ({ key, label: GROUP_LABELS[key], rows: buckets[key] }));
|
||||
}
|
||||
|
||||
/** The cursor's list: group headers are chrome, only sessions are selectable. */
|
||||
export function flattenRows(groups: readonly TuiGroup[]): TuiRow[] {
|
||||
const rows: TuiRow[] = [];
|
||||
for (const group of groups) rows.push(...group.rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an incoming row into a known one. Defined fields win, `undefined` never
|
||||
* clobbers (a live SSE payload carries no transcript fields, a unified refresh
|
||||
* carries no token counters), but a non-empty `sources` list REPLACES rather
|
||||
* than unions: a session that ended must be able to lose its `live` source and
|
||||
* fall to RECENT.
|
||||
*/
|
||||
export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow): TuiSessionRow {
|
||||
const merged: TuiSessionRow = { ...existing };
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (value === undefined) continue;
|
||||
(merged as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
merged.sources = incoming.sources?.length ? [...incoming.sources] : [...(existing.sources ?? [])];
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Search results (pure)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SEARCH_GROUP_LABELS: Record<SearchSourceType, string> = {
|
||||
session: 'SESSIONS',
|
||||
event: 'EVENTS',
|
||||
file: 'FILES',
|
||||
};
|
||||
|
||||
/**
|
||||
* A session snippet opens with the session's own name, which the row already
|
||||
* shows in its first column (`search-service.ts` builds it as
|
||||
* `w1-alpha <em dash> /tmp/alpha`, hence the separator in the pattern).
|
||||
* Dropping the repeat is what keeps a result row from reading as a stutter.
|
||||
*/
|
||||
function withoutLabelPrefix(snippet: string, label: string): string {
|
||||
const rest = snippet.startsWith(label) ? snippet.slice(label.length) : snippet;
|
||||
return rest === snippet ? snippet : rest.replace(/^\s*(?:[—:-]\s*)?/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten `GET /api/search`'s typed groups into the overlay's lines: a header
|
||||
* per group, then its results. Only a result row carries a session id, which is
|
||||
* what the cursor uses to skip headers.
|
||||
*
|
||||
* `isLive` decides which rows can hand the dashboard a session: a history hit
|
||||
* has a session id too, but selecting it would move the cursor to a row that is
|
||||
* not on the list.
|
||||
*/
|
||||
export function buildSearchEntries(
|
||||
groups: readonly SearchResultGroup[],
|
||||
isLive: (sessionId: string) => boolean
|
||||
): TuiSearchEntry[] {
|
||||
const entries: TuiSearchEntry[] = [];
|
||||
for (const group of groups) {
|
||||
if (group.results.length === 0) continue;
|
||||
entries.push({ kind: 'header', text: SEARCH_GROUP_LABELS[group.type] ?? group.type.toUpperCase() });
|
||||
for (const result of group.results) {
|
||||
const live = result.jumpTo.kind === 'session' && isLive(result.sessionId);
|
||||
const label = result.jumpTo.relativePath ?? result.sessionName ?? result.sessionId.slice(0, 8);
|
||||
entries.push({
|
||||
kind: 'result',
|
||||
text: label,
|
||||
detail: withoutLabelPrefix(result.snippet, label),
|
||||
sessionId: result.sessionId,
|
||||
live,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** First selectable row, or -1 when the list is all headers (or empty). */
|
||||
export function firstSearchIndex(entries: readonly TuiSearchEntry[]): number {
|
||||
return entries.findIndex((entry) => entry.kind === 'result');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the search cursor by `delta` result rows, skipping headers and stopping
|
||||
* at both ends (wrapping a search result list scrolls past the answer the user
|
||||
* was reading).
|
||||
*/
|
||||
export function moveSearchIndex(entries: readonly TuiSearchEntry[], index: number, delta: number): number {
|
||||
const step = Math.trunc(delta);
|
||||
if (step === 0) return index;
|
||||
const direction = step > 0 ? 1 : -1;
|
||||
let current = index;
|
||||
for (let remaining = Math.abs(step); remaining > 0; remaining--) {
|
||||
let next = current + direction;
|
||||
while (next >= 0 && next < entries.length && entries[next].kind !== 'result') next += direction;
|
||||
if (next < 0 || next >= entries.length) break;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's state. Update methods mutate in place (one store per TUI
|
||||
* process, no subscribers) and every derived view is recomputed from scratch,
|
||||
* which keeps "what is on screen" a pure function of the stored facts.
|
||||
*/
|
||||
export class TuiModelStore implements TuiRenderModel {
|
||||
private sessionsById = new Map<string, TuiSessionRow>();
|
||||
private approvalsBySession = new Map<string, ApprovalItem>();
|
||||
private _revision = 0;
|
||||
|
||||
selectedId: string | null = null;
|
||||
connection: TuiConnectionStatus = 'connected';
|
||||
mode: TuiUiMode = 'list';
|
||||
header: TuiHeaderInfo = {};
|
||||
preview: TuiPreview | null = null;
|
||||
message: TuiMessage | null = null;
|
||||
confirm: TuiConfirmState | null = null;
|
||||
picker: TuiPickerState | null = null;
|
||||
prompt: TuiPromptState | null = null;
|
||||
search: TuiSearchState | null = null;
|
||||
digest: TuiDigestState | null = null;
|
||||
recentLimit: number;
|
||||
|
||||
constructor(options: GroupOptions = {}) {
|
||||
this.recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped by every mutating method. The app layer repaints when this changed
|
||||
* (plus on resize and on the animation tick), which is what keeps an idle
|
||||
* dashboard from redrawing itself. Writing a public field directly bypasses
|
||||
* it, so state changes go through the methods below.
|
||||
*/
|
||||
get revision(): number {
|
||||
return this._revision;
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this._revision++;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
upsertSession(session: TuiSessionRow): void {
|
||||
this.mutate(() => {
|
||||
const existing = this.sessionsById.get(session.sessionId);
|
||||
this.sessionsById.set(session.sessionId, existing ? mergeSessionRow(existing, session) : { ...session });
|
||||
});
|
||||
}
|
||||
|
||||
removeSession(sessionId: string): void {
|
||||
this.mutate(() => {
|
||||
this.sessionsById.delete(sessionId);
|
||||
this.approvalsBySession.delete(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Full refresh (a `GET /api/sessions/unified` poll): the server is authoritative. */
|
||||
replaceSessions(sessions: readonly TuiSessionRow[]): void {
|
||||
this.mutate(() => {
|
||||
this.sessionsById.clear();
|
||||
for (const session of sessions) this.sessionsById.set(session.sessionId, { ...session });
|
||||
});
|
||||
}
|
||||
|
||||
setApprovals(items: readonly ApprovalItem[]): void {
|
||||
this.mutate(() => {
|
||||
this.approvalsBySession.clear();
|
||||
// One active item per session is an inbox invariant; the newest wins if
|
||||
// that ever stops being true.
|
||||
for (const item of items) this.approvalsBySession.set(item.sessionId, item);
|
||||
});
|
||||
}
|
||||
|
||||
sessions(): TuiSessionRow[] {
|
||||
return [...this.sessionsById.values()];
|
||||
}
|
||||
|
||||
// ── Chrome ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setConnection(status: TuiConnectionStatus): void {
|
||||
if (this.connection === status) return;
|
||||
this.connection = status;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setHeader(header: TuiHeaderInfo): void {
|
||||
this.header = { ...this.header, ...header };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setPreview(preview: TuiPreview | null): void {
|
||||
this.preview = preview;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setMode(mode: TuiUiMode): void {
|
||||
if (this.mode === mode) return;
|
||||
this.mode = mode;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setMessage(message: TuiMessage | null): void {
|
||||
this.message = message;
|
||||
this.mode = message ? 'message' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Show (or clear) the overlay chooser. Setting one takes the keyboard. */
|
||||
setPicker(picker: TuiPickerState | null): void {
|
||||
this.picker = picker;
|
||||
this.mode = picker ? 'new-session' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Open (or close) the one-line prompt composer. Setting one takes the keyboard. */
|
||||
setPrompt(prompt: TuiPromptState | null): void {
|
||||
this.prompt = prompt;
|
||||
this.mode = prompt ? 'prompt' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Replace the composer's editor state, keeping the target session. */
|
||||
updatePrompt(composer: TuiPromptState['composer']): void {
|
||||
if (!this.prompt || this.prompt.composer === composer) return;
|
||||
this.prompt = { ...this.prompt, composer };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setSearch(search: TuiSearchState | null): void {
|
||||
this.search = search;
|
||||
this.mode = search ? 'search' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Fold a partial update into the open search overlay. No-op when it is closed. */
|
||||
updateSearch(patch: Partial<TuiSearchState>): void {
|
||||
if (!this.search) return;
|
||||
this.search = { ...this.search, ...patch };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setDigest(digest: TuiDigestState | null): void {
|
||||
this.digest = digest;
|
||||
this.mode = digest ? 'digest' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the digest by `delta` lines. `capacity` is how many lines the box
|
||||
* shows, so the last page cannot scroll into empty space.
|
||||
*/
|
||||
scrollDigest(delta: number, capacity: number): void {
|
||||
if (!this.digest) return;
|
||||
const room = Math.max(0, this.digest.lines.length - Math.max(1, Math.trunc(capacity)));
|
||||
const offset = Math.min(Math.max(0, this.digest.offset + Math.trunc(delta)), room);
|
||||
if (offset === this.digest.offset) return;
|
||||
this.digest = { ...this.digest, offset };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the typed-name confirmation for `x` (kill). Whether what the user typed
|
||||
* AUTHORIZES the kill is `confirmAccepts()` in tui-app, which owns that rule
|
||||
* for every caller: a second copy here answered the same question differently
|
||||
* (it refused the id prefix a mux name carries) and nothing consulted it.
|
||||
*/
|
||||
beginConfirmKill(row: TuiRow, label: string): void {
|
||||
this.confirm = {
|
||||
sessionId: row.session.sessionId,
|
||||
// ⚠️ Passed in, not derived here. `row.session.name ?? id.slice(0,8)`
|
||||
// used to compute it, and `??` falls back only on null/undefined: a
|
||||
// session whose name is the EMPTY STRING (every session the server did
|
||||
// not name) sailed through it and the dialog read "Kill ?". A destructive
|
||||
// prompt that cannot say what it is about to destroy is worse than no
|
||||
// prompt, and it is now one keystroke. The caller passes the same label
|
||||
// the LIST shows, so the dialog names the row the user is looking at.
|
||||
name: label,
|
||||
};
|
||||
this.mode = 'confirm-kill';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Drop whatever overlay owns the keyboard and go back to the list. */
|
||||
closeOverlay(): void {
|
||||
this.confirm = null;
|
||||
this.message = null;
|
||||
this.picker = null;
|
||||
this.prompt = null;
|
||||
this.search = null;
|
||||
this.digest = null;
|
||||
this.mode = 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
// ── Derived views ──────────────────────────────────────────────────────────
|
||||
|
||||
groups(): TuiGroup[] {
|
||||
return groupSessions(buildRows(this.sessions(), this.approvalsBySession), { recentLimit: this.recentLimit });
|
||||
}
|
||||
|
||||
rows(): TuiRow[] {
|
||||
return flattenRows(this.groups());
|
||||
}
|
||||
|
||||
get sessionCount(): number {
|
||||
let count = 0;
|
||||
for (const session of this.sessionsById.values()) if (isLiveRow(session)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Cursor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
selectedSession(): TuiRow | null {
|
||||
if (!this.selectedId) return null;
|
||||
return this.rows().find((row) => row.session.sessionId === this.selectedId) ?? null;
|
||||
}
|
||||
|
||||
/** Select a session by id. Returns false when it is not on screen. */
|
||||
select(sessionId: string): boolean {
|
||||
if (!this.rows().some((row) => row.session.sessionId === sessionId)) return false;
|
||||
this.moveTo(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Move by `delta` rows, skipping group headers and wrapping at both ends. */
|
||||
moveCursor(delta: number): void {
|
||||
const rows = this.rows();
|
||||
if (rows.length === 0) {
|
||||
this.moveTo(null);
|
||||
return;
|
||||
}
|
||||
const current = this.indexOfSelected(rows);
|
||||
if (current < 0) {
|
||||
this.moveTo(rows[delta >= 0 ? 0 : rows.length - 1].session.sessionId);
|
||||
return;
|
||||
}
|
||||
const step = Math.trunc(delta);
|
||||
const next = (((current + step) % rows.length) + rows.length) % rows.length;
|
||||
this.moveTo(rows[next].session.sessionId);
|
||||
}
|
||||
|
||||
/** The 1-9 jump: `n` is the 1-based position in the flattened list. */
|
||||
cursorToIndex(n: number): boolean {
|
||||
const rows = this.rows();
|
||||
const index = Math.trunc(n) - 1;
|
||||
if (index < 0 || index >= rows.length) return false;
|
||||
this.moveTo(rows[index].session.sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private moveTo(sessionId: string | null): void {
|
||||
if (this.selectedId === sessionId) return;
|
||||
this.selectedId = sessionId;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
private indexOfSelected(rows: readonly TuiRow[] = this.rows()): number {
|
||||
if (!this.selectedId) return -1;
|
||||
return rows.findIndex((row) => row.session.sessionId === this.selectedId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a data mutation and keep the cursor sane afterwards: the selected
|
||||
* session stays selected wherever it moved to, and a session that vanished
|
||||
* hands the cursor to whatever now occupies its place.
|
||||
*/
|
||||
private mutate(apply: () => void): void {
|
||||
const previousIndex = this.indexOfSelected();
|
||||
apply();
|
||||
this.touch();
|
||||
const rows = this.rows();
|
||||
if (rows.length === 0) {
|
||||
this.moveTo(null);
|
||||
return;
|
||||
}
|
||||
if (this.selectedId !== null && rows.some((row) => row.session.sessionId === this.selectedId)) return;
|
||||
const index = Math.min(Math.max(previousIndex, 0), rows.length - 1);
|
||||
this.moveTo(rows[index].session.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiModel(options: GroupOptions = {}): TuiModelStore {
|
||||
return new TuiModelStore(options);
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
/**
|
||||
* @fileoverview Pure frame renderer: model + layout in, one string out.
|
||||
*
|
||||
* The frame is absolute-addressed, one `ESC [ <row>;1 H` per line followed by
|
||||
* `ESC [ K`, so nothing ever scrolls and a repaint cannot leave debris. The
|
||||
* caller wraps the result in synchronized-output brackets (DECSET 2026) where
|
||||
* the terminal supports it; that is an IO decision and stays out of here.
|
||||
*
|
||||
* Color is decided by the caller and passed in, never detected here: chalk's
|
||||
* auto-detection is the right answer for the one-shot CLI (see `cli-style.ts`)
|
||||
* but it would make a frame non-deterministic, and "same inputs, same string"
|
||||
* is what makes this module testable. The palette below is the same semantic
|
||||
* vocabulary chalk gives `cli-style` (ok green, warn yellow, err red, info
|
||||
* cyan, muted gray, emph bold), written as raw SGR so the mapping is fixed.
|
||||
*
|
||||
* With `color: false` the frame contains no escape sequences at all beyond the
|
||||
* cursor addressing that puts each line in place.
|
||||
*
|
||||
* @module tui/tui-render
|
||||
*/
|
||||
|
||||
import { clipStyledLine, padDisplay, stripStyles, visibleWidth } from './tui-ansi.js';
|
||||
import { approvalCard } from './tui-approvals.js';
|
||||
import { composerText, composerWindow } from './tui-composer.js';
|
||||
import type { TuiLayout, TuiRect } from './tui-layout.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type { StatusTelemetry } from '../usage-telemetry.js';
|
||||
import type {
|
||||
TuiDigestState,
|
||||
TuiGlyphTier,
|
||||
TuiGroup,
|
||||
TuiPickerState,
|
||||
TuiPromptState,
|
||||
TuiRenderModel,
|
||||
TuiRow,
|
||||
TuiSearchState,
|
||||
TuiSessionRow,
|
||||
TuiSessionState,
|
||||
} from './tui-types.js';
|
||||
|
||||
export interface TuiRenderOptions {
|
||||
/** Emit SGR color. False is NO_COLOR: cursor addressing and nothing else. */
|
||||
color: boolean;
|
||||
glyphs: TuiGlyphTier;
|
||||
/** Animation counter. The WORKING glyph cycles with it. */
|
||||
tick: number;
|
||||
/** Wall clock for elapsed times, passed in so a frame is reproducible. */
|
||||
now: number;
|
||||
/**
|
||||
* Footer entries, already labelled, joined here with the separator glyph.
|
||||
* The app layer passes the keys that actually do something right now (which
|
||||
* verbs are wired up, whether a server is answering); omitting it falls back
|
||||
* to the full keymap below.
|
||||
*/
|
||||
footerKeys?: readonly string[];
|
||||
/**
|
||||
* `[key, what it does]` pairs for the help overlay, same reasoning as
|
||||
* `footerKeys`: the app layer knows which verbs are wired up. Omitting it
|
||||
* falls back to the full keymap.
|
||||
*/
|
||||
helpKeys?: ReadonlyArray<readonly [string, string]>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Palette and glyphs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SGR = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
inverse: '\x1b[7m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
gray: '\x1b[90m',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One word per state, shared by the preview title and the `--list` output so
|
||||
* both surfaces call a session the same thing.
|
||||
*/
|
||||
export const STATE_WORDS: Record<TuiSessionState, string> = {
|
||||
'blocked-permission': 'blocked',
|
||||
'blocked-question': 'blocked',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
recent: 'done',
|
||||
};
|
||||
|
||||
const STATE_COLOR: Record<TuiSessionState, string> = {
|
||||
'blocked-permission': SGR.red,
|
||||
'blocked-question': SGR.red,
|
||||
waiting: SGR.yellow,
|
||||
working: SGR.green,
|
||||
idle: SGR.gray,
|
||||
recent: SGR.gray,
|
||||
};
|
||||
|
||||
export interface TuiGlyphSet {
|
||||
blockedPermission: string;
|
||||
blockedQuestion: string;
|
||||
waiting: string;
|
||||
/** WORKING animates through Claude's own glyph family, a deliberate nod. */
|
||||
working: readonly string[];
|
||||
idle: string;
|
||||
recent: string;
|
||||
cursor: string;
|
||||
rule: string;
|
||||
divider: string;
|
||||
boxTopLeft: string;
|
||||
boxTopRight: string;
|
||||
boxBottomLeft: string;
|
||||
boxBottomRight: string;
|
||||
boxHorizontal: string;
|
||||
boxVertical: string;
|
||||
enter: string;
|
||||
updown: string;
|
||||
separator: string;
|
||||
ellipsis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Every glyph here must clear TWO bars that are easy to miss, and both were
|
||||
* failed at once by the first version of this table.
|
||||
*
|
||||
* WIDTH: the renderer addresses cells by column, so a glyph the terminal draws
|
||||
* two cells wide shifts everything after it. `east_asian_width` W or F is
|
||||
* therefore disqualifying. `✋` (U+270B) was Wide, and being an emoji is also
|
||||
* why fonts render it at emoji size in the middle of a text row.
|
||||
*
|
||||
* COVERAGE: a plain terminal font carries far less than the unicode TIER
|
||||
* implies. The tier answers "is the locale UTF-8", which says nothing about
|
||||
* whether a given codepoint has a glyph.
|
||||
*
|
||||
* One beta tester's font mapped the blocks like this, and it is the profile to
|
||||
* design against because it is an ordinary terminal font, not a broken one:
|
||||
*
|
||||
* RENDERS Latin-1 (·), Box Drawing (─ │), Block Elements (█ ▛ ▐),
|
||||
* Geometric Shapes (○ ▶), General Punctuation (…), Arrows
|
||||
* TOFU Misc Technical (⏎ U+23CE, ⏵ U+23F5), the sparse end of
|
||||
* Dingbats (❯ U+276F)
|
||||
*
|
||||
* So: draw from the blocks on the first line. Dingbats, Miscellaneous
|
||||
* Technical, Miscellaneous Symbols and anything with emoji presentation are
|
||||
* out — that class produced three separate "why are there boxes" reports, one
|
||||
* per glyph, because each was fixed on its own instead of as a class.
|
||||
*/
|
||||
const UNICODE_GLYPHS: TuiGlyphSet = {
|
||||
blockedPermission: '▲',
|
||||
blockedQuestion: '▲',
|
||||
waiting: '!',
|
||||
// Quadrant blocks, which rotate as a spinner and live in the same block as
|
||||
// the `▛█▐` art claude itself draws — proven to render on the font that
|
||||
// failed the dingbats this used to use.
|
||||
working: ['▖', '▘', '▝', '▗'],
|
||||
idle: '○',
|
||||
recent: '✔',
|
||||
cursor: '▶',
|
||||
rule: '─',
|
||||
divider: '│',
|
||||
boxTopLeft: '┌',
|
||||
boxTopRight: '┐',
|
||||
boxBottomLeft: '└',
|
||||
boxBottomRight: '┘',
|
||||
boxHorizontal: '─',
|
||||
boxVertical: '│',
|
||||
enter: '↵',
|
||||
updown: '↑↓',
|
||||
separator: '·',
|
||||
ellipsis: '…',
|
||||
};
|
||||
|
||||
/**
|
||||
* The lowest tier, for terminals that are not known-capable. Every state token
|
||||
* is three columns wide so rows still line up, mirroring what `sc` falls back
|
||||
* to today.
|
||||
*/
|
||||
const ASCII_GLYPHS: TuiGlyphSet = {
|
||||
blockedPermission: '[!]',
|
||||
blockedQuestion: '[?]',
|
||||
waiting: '[w]',
|
||||
working: ['[*]', '[+]', '[x]', '[+]'],
|
||||
idle: '[-]',
|
||||
recent: '[v]',
|
||||
cursor: '>',
|
||||
rule: '-',
|
||||
divider: '|',
|
||||
boxTopLeft: '+',
|
||||
boxTopRight: '+',
|
||||
boxBottomLeft: '+',
|
||||
boxBottomRight: '+',
|
||||
boxHorizontal: '-',
|
||||
boxVertical: '|',
|
||||
enter: 'enter',
|
||||
updown: 'up/dn',
|
||||
separator: '-',
|
||||
ellipsis: '..',
|
||||
};
|
||||
|
||||
/**
|
||||
* Glyphs for a tier. `nerd` currently renders like `unicode`: the tier exists
|
||||
* so detection has somewhere to land and a nerd-font-only set has a home,
|
||||
* without shipping glyphs nobody has reviewed on a real font.
|
||||
*/
|
||||
export function glyphsFor(tier: TuiGlyphTier): TuiGlyphSet {
|
||||
return tier === 'ascii' ? ASCII_GLYPHS : UNICODE_GLYPHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glyph tier from the environment. IO-ish by nature (it reads env), so it takes
|
||||
* the env as an argument and the app layer calls it once at startup. The
|
||||
* known-capable list is a TERM allowlist, plus a UTF-8 locale check and an
|
||||
* explicit override.
|
||||
*/
|
||||
export function detectGlyphTier(env: Record<string, string | undefined>): TuiGlyphTier {
|
||||
const override = env.CODEMAN_TUI_GLYPHS;
|
||||
if (override === 'ascii' || override === 'unicode' || override === 'nerd') return override;
|
||||
const term = env.TERM ?? '';
|
||||
if (term === '' || term === 'dumb') return 'ascii';
|
||||
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || '';
|
||||
if (!/utf-?8/i.test(locale)) return 'ascii';
|
||||
const termProgram = env.TERM_PROGRAM ?? '';
|
||||
if (termProgram.startsWith('iTerm') || term === 'xterm-kitty' || env.WEZTERM_PANE || env.LC_TERMINAL === 'iTerm2') {
|
||||
return 'nerd';
|
||||
}
|
||||
return 'unicode';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Formatting helpers (pure, exported for tests and for the app layer)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Compact age: `45s`, `11m`, `2h`, `3d`. Empty when the anchor is unknown. */
|
||||
export function formatElapsed(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function trimTrailingZero(value: string): string {
|
||||
return value.endsWith('.0') ? value.slice(0, -2) : value;
|
||||
}
|
||||
|
||||
/** Compact token count: `842`, `45.2k`, `1.2M`. Empty when there is nothing to show. */
|
||||
export function formatTokens(total: number): string {
|
||||
if (!Number.isFinite(total) || total <= 0) return '';
|
||||
if (total < 1000) return String(Math.floor(total));
|
||||
if (total < 1_000_000) return `${trimTrailingZero((total / 1000).toFixed(1))}k`;
|
||||
return `${trimTrailingZero((total / 1_000_000).toFixed(1))}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's plan-usage chip: `5h 32% · wk 61%`, the same two windows the web
|
||||
* chip shows (the statusline telemetry carries no others). Empty when the
|
||||
* account reports neither, so the header shows no placeholder for a fact that
|
||||
* does not exist. The separator is passed in because the header's own comes
|
||||
* from the glyph tier, and an ASCII terminal must not get a stray `·`.
|
||||
*/
|
||||
export function formatPlanUsage(usage: StatusTelemetry | null | undefined, separator = ' · '): string {
|
||||
if (!usage) return '';
|
||||
const parts: string[] = [];
|
||||
if (typeof usage.fiveHour?.usedPercentage === 'number') {
|
||||
parts.push(`5h ${Math.round(usage.fiveHour.usedPercentage)}%`);
|
||||
}
|
||||
if (typeof usage.sevenDay?.usedPercentage === 'number') {
|
||||
parts.push(`wk ${Math.round(usage.sevenDay.usedPercentage)}%`);
|
||||
}
|
||||
return parts.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row is called. Same rule as the web history rows, including the
|
||||
* "(no content)" placeholder the transcript reader emits, which is not a title.
|
||||
*/
|
||||
export function rowLabel(session: TuiSessionRow): string {
|
||||
if (session.name) return session.name;
|
||||
const base = (session.workingDir ?? '').split('/').filter(Boolean).pop();
|
||||
// ⚠️ A LIVE pane (it has a mux name) is identified by WHERE it runs, never by
|
||||
// a line scraped out of its transcript. A session created before the user has
|
||||
// typed anything has no prompt to be named after, so the fallback took
|
||||
// whatever the CLI happened to print first: a beta tester's new session
|
||||
// appeared in the list called "Login interrupted", which reads like a failure
|
||||
// report and was in fact a healthy session. A history row is the opposite
|
||||
// case, where the prompt IS the identity, so it keeps the old order.
|
||||
if (session.muxName && base) return base;
|
||||
const prompt = (session.firstPrompt ?? '').trim();
|
||||
if (prompt && prompt !== '(no content)') return prompt;
|
||||
return base || session.sessionId.slice(0, 8);
|
||||
}
|
||||
|
||||
/** Keep the tail of a path: the last segments identify it, the root never does. */
|
||||
function truncatePathLeft(path: string, width: number, ellipsis: string): string {
|
||||
if (width <= 0) return '';
|
||||
if (visibleWidth(path) <= width) return path;
|
||||
const keep = Math.max(0, width - visibleWidth(ellipsis));
|
||||
return ellipsis + path.slice(path.length - keep);
|
||||
}
|
||||
|
||||
function tokensOf(session: TuiSessionRow): number {
|
||||
return (session.inputTokens ?? 0) + (session.outputTokens ?? 0);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Painting
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Painter = (text: string, code: string) => string;
|
||||
|
||||
function painterFor(enabled: boolean): Painter {
|
||||
return enabled ? (text, code) => (text === '' ? text : `${code}${text}${SGR.reset}`) : (text) => text;
|
||||
}
|
||||
|
||||
function stateGlyph(row: TuiRow, glyphs: TuiGlyphSet, tick: number): string {
|
||||
switch (row.state) {
|
||||
case 'blocked-permission':
|
||||
return glyphs.blockedPermission;
|
||||
case 'blocked-question':
|
||||
return glyphs.blockedQuestion;
|
||||
case 'waiting':
|
||||
return glyphs.waiting;
|
||||
case 'working': {
|
||||
const frames = glyphs.working;
|
||||
const index = ((Math.trunc(tick) % frames.length) + frames.length) % frames.length;
|
||||
return frames[index];
|
||||
}
|
||||
case 'idle':
|
||||
return glyphs.idle;
|
||||
case 'recent':
|
||||
return glyphs.recent;
|
||||
}
|
||||
}
|
||||
|
||||
function centered(text: string, width: number): string {
|
||||
const pad = Math.max(0, Math.floor((width - visibleWidth(text)) / 2));
|
||||
return padDisplay(`${' '.repeat(pad)}${text}`, width);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rows and groups
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RowContext {
|
||||
width: number;
|
||||
/** 1-based position in the flattened list; only 1-9 get a jump digit. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
twoLine: boolean;
|
||||
glyphs: TuiGlyphSet;
|
||||
opts: TuiRenderOptions;
|
||||
}
|
||||
|
||||
function renderRowLines(row: TuiRow, ctx: RowContext): string[] {
|
||||
// A selected row is one inverse-video block, so its parts are built unpainted:
|
||||
// an inner reset would punch a hole in the highlight.
|
||||
const inverse = ctx.selected && ctx.opts.color;
|
||||
const paint = painterFor(ctx.opts.color && !inverse);
|
||||
const { session } = row;
|
||||
|
||||
const marker = ctx.selected ? padDisplay(ctx.glyphs.cursor, 2) : ' ';
|
||||
const digit = ctx.index >= 1 && ctx.index <= 9 ? `${ctx.index} ` : ' ';
|
||||
|
||||
const glyph = paint(stateGlyph(row, ctx.glyphs, ctx.opts.tick), STATE_COLOR[row.state]);
|
||||
const elapsed = row.since > 0 ? formatElapsed(ctx.opts.now - row.since) : '';
|
||||
const tokens = formatTokens(tokensOf(session));
|
||||
const rightParts = [glyph, paint(elapsed, SGR.gray)];
|
||||
if (!ctx.twoLine && tokens) rightParts.push(paint(tokens, SGR.gray));
|
||||
const right = rightParts.filter((part) => part !== '').join(' ');
|
||||
|
||||
const mode = session.mode && session.mode !== 'claude' ? session.mode : '';
|
||||
const nameWidth = Math.max(4, ctx.width - visibleWidth(marker + digit) - visibleWidth(right) - 1);
|
||||
const label = rowLabel(session);
|
||||
const name = mode ? `${label} ${paint(mode, SGR.magenta)}` : label;
|
||||
|
||||
const first = padDisplay(`${marker}${digit}${padDisplay(name, nameWidth)} ${right}`, ctx.width);
|
||||
const lines = [first];
|
||||
|
||||
if (ctx.twoLine) {
|
||||
const detail = [truncatePathLeft(session.workingDir ?? '', Math.max(0, ctx.width - 8), ctx.glyphs.ellipsis)];
|
||||
if (mode) detail.push(mode);
|
||||
if (tokens) detail.push(tokens);
|
||||
const text = detail.filter((part) => part !== '').join(` ${ctx.glyphs.separator} `);
|
||||
lines.push(padDisplay(` ${paint(text, SGR.gray)}`, ctx.width));
|
||||
}
|
||||
|
||||
return inverse ? lines.map((line) => `${SGR.inverse}${line}${SGR.reset}`) : lines;
|
||||
}
|
||||
|
||||
function renderGroupHeader(group: TuiGroup, width: number, glyphs: TuiGlyphSet, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const label = ` ${group.label} `;
|
||||
const fill = Math.max(0, width - visibleWidth(label));
|
||||
return padDisplay(`${paint(label, SGR.bold)}${paint(glyphs.rule.repeat(fill), SGR.gray)}`, width);
|
||||
}
|
||||
|
||||
export interface TuiListEntry {
|
||||
text: string;
|
||||
/** Set on the lines that belong to a session row, so the window can chase the cursor. */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function buildListEntries(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): TuiListEntry[] {
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const width = layout.list.width;
|
||||
const entries: TuiListEntry[] = [];
|
||||
let index = 0;
|
||||
for (const group of model.groups()) {
|
||||
if (group.rows.length === 0) continue;
|
||||
entries.push({ text: renderGroupHeader(group, width, glyphs, opts) });
|
||||
for (const row of group.rows) {
|
||||
index++;
|
||||
const ctx: RowContext = {
|
||||
width,
|
||||
index,
|
||||
selected: row.session.sessionId === model.selectedId,
|
||||
twoLine: layout.rowHeight === 2,
|
||||
glyphs,
|
||||
opts,
|
||||
};
|
||||
for (const text of renderRowLines(row, ctx)) entries.push({ text, sessionId: row.session.sessionId });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* First visible entry, scrolling the minimum needed to keep the selected row on
|
||||
* screen. Deterministic on purpose: the window is derived, never remembered, so
|
||||
* two identical models render identically.
|
||||
*/
|
||||
export function computeListWindow(
|
||||
entries: readonly TuiListEntry[],
|
||||
capacity: number,
|
||||
selectedId: string | null
|
||||
): number {
|
||||
if (capacity <= 0 || entries.length <= capacity) return 0;
|
||||
const maxStart = entries.length - capacity;
|
||||
if (!selectedId) return 0;
|
||||
const first = entries.findIndex((entry) => entry.sessionId === selectedId);
|
||||
if (first < 0) return 0;
|
||||
let last = first;
|
||||
while (last + 1 < entries.length && entries[last + 1].sessionId === selectedId) last++;
|
||||
let start = 0;
|
||||
if (last >= capacity) start = Math.min(last - capacity + 1, maxStart);
|
||||
if (first < start) start = first;
|
||||
return start;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Preview
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pending dialog, drawn above the tail: the question, the parsed options
|
||||
* with their digits, and the keys that answer them. Red for a permission or
|
||||
* question prompt, yellow for an idle one, the same severity vocabulary the web
|
||||
* inbox uses.
|
||||
*/
|
||||
export function renderApprovalCard(
|
||||
item: ApprovalItem,
|
||||
width: number,
|
||||
glyphs: TuiGlyphSet,
|
||||
opts: TuiRenderOptions
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const card = approvalCard(item);
|
||||
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
|
||||
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
|
||||
const lines: string[] = [];
|
||||
const push = (text: string, style: string): void => {
|
||||
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
|
||||
};
|
||||
|
||||
push(` ${glyph} ${card.title}`, color);
|
||||
for (const detail of card.detail) push(` ${detail}`, SGR.gray);
|
||||
for (const option of card.options) push(` ${option.n}. ${option.label}`, '');
|
||||
push(` ${card.hint}`, SGR.gray);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The card may take half the pane at most: the tail is why the pane exists. */
|
||||
function cardCapacity(height: number): number {
|
||||
return Math.max(0, Math.floor((height - 1) / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* `name · mode · dir · state`, with the DIRECTORY absorbing the squeeze: the
|
||||
* state word is the one fact the pane exists to confirm, so it must survive a
|
||||
* narrow preview that a full path would push off the end.
|
||||
*/
|
||||
function previewTitle(row: TuiRow, width: number, glyphs: TuiGlyphSet): string {
|
||||
const { session } = row;
|
||||
const sep = ` ${glyphs.separator} `;
|
||||
const head = ` ${rowLabel(session)}${sep}${session.mode ?? 'claude'}`;
|
||||
const tail = `${sep}${STATE_WORDS[row.state]}`;
|
||||
const dirBudget = width - visibleWidth(head) - visibleWidth(tail) - visibleWidth(sep);
|
||||
const dir = session.workingDir ? truncatePathLeft(session.workingDir, Math.max(0, dirBudget), glyphs.ellipsis) : '';
|
||||
return clipStyledLine(dir ? `${head}${sep}${dir}${tail}` : `${head}${tail}`, width);
|
||||
}
|
||||
|
||||
function buildPreviewLines(model: TuiRenderModel, rect: TuiRect, opts: TuiRenderOptions): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const lines: string[] = [];
|
||||
const selected = model.selectedId
|
||||
? (model
|
||||
.groups()
|
||||
.flatMap((group) => group.rows)
|
||||
.find((row) => row.session.sessionId === model.selectedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!selected) {
|
||||
lines.push(padDisplay(paint(' no session selected', SGR.gray), rect.width));
|
||||
} else {
|
||||
lines.push(padDisplay(paint(previewTitle(selected, rect.width, glyphs), SGR.bold), rect.width));
|
||||
}
|
||||
|
||||
const budget = cardCapacity(rect.height);
|
||||
if (selected?.approval && budget > 0) {
|
||||
for (const line of renderApprovalCard(selected.approval, rect.width, glyphs, opts).slice(0, budget)) {
|
||||
lines.push(line);
|
||||
}
|
||||
if (lines.length < rect.height) lines.push(' '.repeat(rect.width));
|
||||
}
|
||||
|
||||
const body = previewBody(model, selected, rect, opts, rect.height - lines.length);
|
||||
for (const line of body) lines.push(line);
|
||||
while (lines.length < rect.height) lines.push(' '.repeat(rect.width));
|
||||
return lines.slice(0, Math.max(0, rect.height));
|
||||
}
|
||||
|
||||
function previewBody(
|
||||
model: TuiRenderModel,
|
||||
selected: TuiRow | null,
|
||||
rect: TuiRect,
|
||||
opts: TuiRenderOptions,
|
||||
capacity: number
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
if (capacity <= 0) return [];
|
||||
const hint = (text: string): string[] => [padDisplay(paint(` ${text}`, SGR.gray), rect.width)];
|
||||
|
||||
if (!selected) return [];
|
||||
if (model.connection === 'degraded' || model.connection === 'down') {
|
||||
return hint('preview unavailable while the server is down');
|
||||
}
|
||||
const preview = model.preview;
|
||||
if (!preview || preview.sessionId !== selected.session.sessionId) return hint('loading preview…');
|
||||
if (preview.note) return hint(preview.note);
|
||||
if (preview.error) return hint(preview.error);
|
||||
|
||||
const trimmed = [...preview.lines];
|
||||
while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop();
|
||||
if (trimmed.length === 0) return hint('(no output yet)');
|
||||
// The tail carries the session's OWN colors, which is the point of the pane,
|
||||
// but under NO_COLOR they must go too.
|
||||
return trimmed
|
||||
.slice(-capacity)
|
||||
.map((line) => padDisplay(` ${clipStyledLine(opts.color ? line : stripStyles(line), rect.width - 1)}`, rect.width));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Chrome
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
|
||||
export function pendingApprovalCount(model: TuiRenderModel): number {
|
||||
let count = 0;
|
||||
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderHeaderLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const { hostname, instance, version, planUsage } = model.header;
|
||||
const facts = [
|
||||
instance ? `${hostname ?? ''}:${instance}` : (hostname ?? ''),
|
||||
version ? `v${version}` : '',
|
||||
`${model.sessionCount} session${model.sessionCount === 1 ? '' : 's'}`,
|
||||
planUsage ?? '',
|
||||
].filter((part) => part !== '');
|
||||
|
||||
const pending = pendingApprovalCount(model);
|
||||
const badge = pending > 0 ? `${paint(`${glyphs.blockedPermission} ${pending}`, SGR.red)} ` : '';
|
||||
const left = ` ${paint('codeman', SGR.bold)} ${badge}${paint(facts.join(` ${glyphs.separator} `), SGR.gray)}`;
|
||||
const right = paint('? help q quit ', SGR.gray);
|
||||
const gap = layout.cols - visibleWidth(left) - visibleWidth(right);
|
||||
if (gap < 1) return padDisplay(left, layout.cols);
|
||||
return `${left}${' '.repeat(gap)}${right}`;
|
||||
}
|
||||
|
||||
function renderBannerLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const [text, color] =
|
||||
model.connection === 'degraded'
|
||||
? ['server not running: attach only', SGR.yellow]
|
||||
: model.connection === 'reconnecting'
|
||||
? ['reconnecting to the server…', SGR.yellow]
|
||||
: ['server unreachable', SGR.red];
|
||||
return padDisplay(paint(` ${glyphs.blockedPermission} ${text}`, color), layout.cols);
|
||||
}
|
||||
|
||||
const FOOTER_KEYS: Record<string, (glyphs: TuiGlyphSet) => string> = {
|
||||
list: (g) =>
|
||||
[
|
||||
`${g.updown} select`,
|
||||
`${g.enter} attach`,
|
||||
'1-9 switch',
|
||||
'y/n answer',
|
||||
'p prompt',
|
||||
'n new',
|
||||
'x kill',
|
||||
'/ search',
|
||||
'g digest',
|
||||
'? help',
|
||||
'q quit',
|
||||
].join(` ${g.separator} `),
|
||||
help: (g) => `esc ${g.separator} ? close`,
|
||||
'confirm-kill': (g) => `y kill ${g.separator} any other key cancels`,
|
||||
message: () => 'esc dismiss',
|
||||
prompt: (g) => `${g.enter} send ${g.separator} esc cancel`,
|
||||
search: (g) => `${g.updown} results ${g.separator} ${g.enter} open ${g.separator} esc close`,
|
||||
digest: (g) => `j/k ${g.separator} ${g.updown} scroll ${g.separator} esc close`,
|
||||
'new-session': (g) => `${g.updown} select ${g.separator} ${g.enter} choose ${g.separator} esc cancel`,
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's prefix. Fixed width on purpose: the terminal cursor is placed
|
||||
* by column arithmetic (`composerCursorCell`), and a prefix that changed with
|
||||
* the session name would move the cursor with it.
|
||||
*/
|
||||
export const COMPOSER_PREFIX = ' > ';
|
||||
|
||||
function renderComposerLine(prompt: TuiPromptState, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const window = composerWindow(prompt.composer, Math.max(1, layout.cols - visibleWidth(COMPOSER_PREFIX)));
|
||||
return padDisplay(`${paint(COMPOSER_PREFIX, SGR.cyan)}${window.text}`, layout.cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the terminal's own cursor belongs, or null when nothing is being typed
|
||||
* into a single-line editor. The app shows the cursor there and hides it
|
||||
* otherwise, because a blinking cursor parked in a dashboard reads as a bug.
|
||||
*/
|
||||
export function composerCursorCell(model: TuiRenderModel, layout: TuiLayout): { row: number; col: number } | null {
|
||||
if (model.mode !== 'prompt' || !model.prompt || layout.footer.height <= 0) return null;
|
||||
const prefix = visibleWidth(COMPOSER_PREFIX);
|
||||
const window = composerWindow(model.prompt.composer, Math.max(1, layout.cols - prefix));
|
||||
return { row: layout.footer.row, col: Math.min(layout.cols, prefix + 1 + window.cursorColumn) };
|
||||
}
|
||||
|
||||
function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
if (model.mode === 'prompt' && model.prompt) return renderComposerLine(model.prompt, layout, opts);
|
||||
const text = opts.footerKeys
|
||||
? opts.footerKeys.join(` ${glyphs.separator} `)
|
||||
: (FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list)(glyphs);
|
||||
return padDisplay(paint(clipStyledLine(` ${text}`, layout.cols), SGR.gray), layout.cols);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Overlays
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OverlayContent {
|
||||
title: string;
|
||||
lines: string[];
|
||||
/**
|
||||
* Floor for the box's inner width. The search and digest panels are lists
|
||||
* people scan, so they keep a stable width instead of snapping around their
|
||||
* longest current line.
|
||||
*/
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
function wrapText(text: string, width: number): string[] {
|
||||
if (width <= 0) return [];
|
||||
const out: string[] = [];
|
||||
let line = '';
|
||||
for (const word of text.split(/\s+/).filter((part) => part !== '')) {
|
||||
const candidate = line === '' ? word : `${line} ${word}`;
|
||||
if (visibleWidth(candidate) > width && line !== '') {
|
||||
out.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
if (line !== '') out.push(line);
|
||||
return out.length > 0 ? out : [''];
|
||||
}
|
||||
|
||||
function helpLines(glyphs: TuiGlyphSet, custom?: ReadonlyArray<readonly [string, string]>): string[] {
|
||||
const pairs: ReadonlyArray<readonly [string, string]> = custom ?? [
|
||||
[`${glyphs.updown} / j k`, 'select'],
|
||||
[glyphs.enter, 'attach'],
|
||||
['1-9', 'jump'],
|
||||
['y / n', 'answer the pending approval'],
|
||||
['p', 'send a prompt'],
|
||||
['n', 'new session'],
|
||||
['x', 'kill (typed confirmation)'],
|
||||
['/', 'search'],
|
||||
['g', 'away digest'],
|
||||
['?', 'this help'],
|
||||
['q', 'quit'],
|
||||
];
|
||||
const keyWidth = Math.max(...pairs.map(([key]) => visibleWidth(key)));
|
||||
return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`);
|
||||
}
|
||||
|
||||
/** Longest item list a picker overlay shows, however tall the terminal is. */
|
||||
const PICKER_MAX_ROWS = 10;
|
||||
|
||||
/**
|
||||
* A picker's lines: hint, a window of items around the cursor, then the filter
|
||||
* echo. Windowed rather than clipped, so the selected item is always visible in
|
||||
* a long case list.
|
||||
*/
|
||||
function pickerLines(picker: TuiPickerState, glyphs: TuiGlyphSet, capacity: number): string[] {
|
||||
const head: string[] = picker.hint ? [picker.hint, ''] : [];
|
||||
const tail: string[] = picker.filter === undefined ? [] : ['', `filter: ${picker.filter}_`];
|
||||
if (picker.items.length === 0) return [...head, '(nothing to choose)', ...tail];
|
||||
|
||||
const budget = Math.max(1, Math.min(PICKER_MAX_ROWS, capacity - head.length - tail.length));
|
||||
const first = Math.max(0, Math.min(picker.index - Math.floor(budget / 2), picker.items.length - budget));
|
||||
const rows = picker.items.slice(first, first + budget).map((item, i) => {
|
||||
const marker = first + i === picker.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor));
|
||||
return `${marker} ${item.label}${item.detail ? ` ${item.detail}` : ''}`;
|
||||
});
|
||||
return [...head, ...rows, ...tail];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/` overlay: the query with a caret, one status line, then the results.
|
||||
*
|
||||
* The caret is a trailing `_` rather than the terminal's own cursor, and that is
|
||||
* why the search keymap leaves left/right to the result list: a caret that
|
||||
* cannot move is honest, an invisible one that can is not.
|
||||
*/
|
||||
function searchLines(state: TuiSearchState, glyphs: TuiGlyphSet, capacity: number): string[] {
|
||||
const head = [`${composerText(state.composer)}_`];
|
||||
if (state.note) head.push(state.note);
|
||||
head.push('');
|
||||
|
||||
const budget = Math.max(1, capacity - head.length);
|
||||
if (state.entries.length === 0) {
|
||||
return [...head, state.status === 'searching' ? 'searching…' : '(type to search sessions, events and files)'];
|
||||
}
|
||||
const first = Math.max(0, Math.min(state.index - Math.floor(budget / 2), state.entries.length - budget));
|
||||
const rows = state.entries.slice(first, first + budget).map((entry, i) => {
|
||||
if (entry.kind === 'header') return entry.text;
|
||||
const marker = first + i === state.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor));
|
||||
return `${marker} ${entry.text}${entry.detail ? ` ${entry.detail}` : ''}`;
|
||||
});
|
||||
return [...head, ...rows];
|
||||
}
|
||||
|
||||
/** Lines an overlay box can show inside its border, given the body's height. */
|
||||
function overlayCapacity(height: number): number {
|
||||
return Math.max(1, height - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many digest lines fit. Exported because the app scrolls by pages and must
|
||||
* not scroll the last page into empty space, which needs this exact number.
|
||||
*/
|
||||
export function digestCapacity(layout: TuiLayout): number {
|
||||
return overlayCapacity(layout.body.height);
|
||||
}
|
||||
|
||||
function digestLines(state: TuiDigestState, capacity: number): string[] {
|
||||
const offset = Math.min(Math.max(0, state.offset), Math.max(0, state.lines.length - 1));
|
||||
return state.lines.slice(offset, offset + capacity);
|
||||
}
|
||||
|
||||
function overlayContent(
|
||||
model: TuiRenderModel,
|
||||
opts: TuiRenderOptions,
|
||||
width: number,
|
||||
height: number
|
||||
): OverlayContent | null {
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const panelWidth = Math.max(20, Math.min(width - 8, 72));
|
||||
switch (model.mode) {
|
||||
case 'help':
|
||||
return { title: 'Keys', lines: helpLines(glyphs, opts.helpKeys) };
|
||||
case 'search': {
|
||||
if (!model.search) return null;
|
||||
return {
|
||||
title: 'Search',
|
||||
lines: searchLines(model.search, glyphs, overlayCapacity(height)),
|
||||
minWidth: panelWidth,
|
||||
};
|
||||
}
|
||||
case 'digest': {
|
||||
if (!model.digest) return null;
|
||||
return {
|
||||
title: model.digest.title,
|
||||
lines: digestLines(model.digest, overlayCapacity(height)),
|
||||
minWidth: panelWidth,
|
||||
};
|
||||
}
|
||||
case 'new-session': {
|
||||
if (!model.picker) return null;
|
||||
return { title: model.picker.title, lines: pickerLines(model.picker, glyphs, Math.max(1, height - 2)) };
|
||||
}
|
||||
case 'confirm-kill': {
|
||||
if (!model.confirm) return null;
|
||||
return {
|
||||
title: 'Kill session',
|
||||
lines: [`Kill ${model.confirm.name}?`, '', 'press y to kill, any other key cancels'],
|
||||
};
|
||||
}
|
||||
case 'message':
|
||||
if (!model.message) return null;
|
||||
return {
|
||||
title: model.message.tone === 'err' ? 'Error' : model.message.tone === 'warn' ? 'Warning' : 'Notice',
|
||||
lines: wrapText(model.message.text, Math.max(8, width - 8)),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paint an overlay box over the body, centered, replacing whole terminal rows. */
|
||||
function applyOverlay(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void {
|
||||
const body = layout.body;
|
||||
if (body.height < 3 || body.width < 12) return;
|
||||
const content = overlayContent(model, opts, body.width, body.height);
|
||||
if (!content) return;
|
||||
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const maxInner = body.width - 4;
|
||||
const visible = content.lines.slice(0, Math.max(1, body.height - 2));
|
||||
const inner = Math.min(
|
||||
maxInner,
|
||||
Math.max(content.minWidth ?? 0, visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line)))
|
||||
);
|
||||
const boxWidth = inner + 4;
|
||||
const boxHeight = visible.length + 2;
|
||||
const left = body.col + Math.max(0, Math.floor((body.width - boxWidth) / 2));
|
||||
const top = body.row + Math.max(0, Math.floor((body.height - boxHeight) / 2));
|
||||
|
||||
const titleText = ` ${content.title} `;
|
||||
const titleFill = Math.max(0, inner + 2 - visibleWidth(titleText));
|
||||
const boxLines = [
|
||||
`${glyphs.boxTopLeft}${titleText}${glyphs.boxHorizontal.repeat(titleFill)}${glyphs.boxTopRight}`,
|
||||
...visible.map((line) => `${glyphs.boxVertical} ${padDisplay(line, inner)} ${glyphs.boxVertical}`),
|
||||
`${glyphs.boxBottomLeft}${glyphs.boxHorizontal.repeat(inner + 2)}${glyphs.boxBottomRight}`,
|
||||
];
|
||||
|
||||
for (let i = 0; i < boxLines.length; i++) {
|
||||
const row = top + i - 1;
|
||||
if (row < 0 || row >= lines.length) continue;
|
||||
lines[row] = `${' '.repeat(left - 1)}${paint(boxLines[i], SGR.cyan)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Frame
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeBody(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void {
|
||||
const { list, preview, divider } = layout;
|
||||
if (list.height <= 0) return;
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
|
||||
const entries = buildListEntries(model, layout, opts);
|
||||
if (entries.length === 0) {
|
||||
const hint = paint('No sessions. n to start one, q to quit.', SGR.gray);
|
||||
const row = list.row + Math.floor((list.height - 1) / 2);
|
||||
lines[row - 1] = centered(hint, layout.cols);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = computeListWindow(entries, list.height, model.selectedId);
|
||||
const previewLines = preview ? buildPreviewLines(model, preview, opts) : [];
|
||||
|
||||
for (let i = 0; i < list.height; i++) {
|
||||
const left = entries[start + i]?.text ?? ' '.repeat(list.width);
|
||||
if (!preview || !divider) {
|
||||
lines[list.row - 1 + i] = left;
|
||||
continue;
|
||||
}
|
||||
const right = previewLines[i] ?? ' '.repeat(preview.width);
|
||||
lines[list.row - 1 + i] = `${left}${paint(glyphs.divider, SGR.gray)}${right}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole frame as one string: absolute cursor addressing per line, each line
|
||||
* closed with an erase-to-end so a shorter line cannot leave the previous
|
||||
* frame's tail behind.
|
||||
*/
|
||||
export function renderFrame(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const lines: string[] = new Array<string>(layout.rows).fill('');
|
||||
lines[0] = renderHeaderLine(model, layout, opts);
|
||||
if (layout.banner) lines[layout.banner.row - 1] = renderBannerLine(model, layout, opts);
|
||||
writeBody(lines, model, layout, opts);
|
||||
if (layout.footer.height > 0) lines[layout.footer.row - 1] = renderFooterLine(model, layout, opts);
|
||||
applyOverlay(lines, model, layout, opts);
|
||||
|
||||
let frame = '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
frame += `\x1b[${i + 1};1H${clipStyledLine(lines[i], layout.cols)}\x1b[K`;
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @fileoverview Pure SSE wire parsing, event classification and reconnect math.
|
||||
*
|
||||
* Node has no `EventSource`, so the TUI reads `GET /api/events` as a raw stream
|
||||
* and decodes the wire format here. Everything in this module is pure: bytes
|
||||
* (as decoded strings) in, frames out. The socket, the timers and the backoff
|
||||
* loop live in `tui-client.ts`.
|
||||
*
|
||||
* Three wire details this parser exists to get right:
|
||||
*
|
||||
* 1. **Frames split across chunk boundaries.** A TCP read can end anywhere,
|
||||
* including between the `\r` and the `\n` of a CRLF, so a lone trailing
|
||||
* `\r` is held back rather than treated as a line end.
|
||||
* 2. **Comments are not frames.** The server appends a `:pppp…` padding line
|
||||
* after a frame while a Cloudflare tunnel is up (it flushes the proxy
|
||||
* buffer) and that line carries no blank line after it. Dispatch happens on
|
||||
* a blank line and on nothing else, so padding cannot split a frame.
|
||||
* 3. **The keepalive is a NAMED event** (`sse:heartbeat`), because an SSE
|
||||
* comment is invisible to a browser `EventSource` by spec. We treat ANY
|
||||
* inbound bytes as liveness, comments included, which is why comments need
|
||||
* no representation in the returned frames.
|
||||
*
|
||||
* @module tui/tui-sse
|
||||
*/
|
||||
|
||||
import {
|
||||
ApprovalPending,
|
||||
ApprovalResolved,
|
||||
ApprovalUpdated,
|
||||
Heartbeat,
|
||||
Init,
|
||||
MuxCreated,
|
||||
MuxDied,
|
||||
MuxKilled,
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
SessionCliInfo,
|
||||
SessionCompletion,
|
||||
SessionCreated,
|
||||
SessionDeleted,
|
||||
SessionError,
|
||||
SessionExit,
|
||||
SessionIdle,
|
||||
SessionInteractive,
|
||||
SessionPinned,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
SessionUpdated,
|
||||
SessionWorking,
|
||||
} from '../web/sse-events.js';
|
||||
|
||||
/** One dispatched SSE frame. `event` defaults to `message` per the spec. */
|
||||
export interface SseFrame {
|
||||
event: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling on the unterminated tail the parser will hold. The `init` frame
|
||||
* carries the whole light state and is legitimately large, so this is not a
|
||||
* frame-size limit but a guard against a non-SSE endpoint streaming something
|
||||
* with no line terminators at all.
|
||||
*/
|
||||
export const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Incremental decoder. One instance per connection; `reset()` on reconnect. */
|
||||
export class SseFrameParser {
|
||||
private buffer = '';
|
||||
private eventName = '';
|
||||
private dataLines: string[] = [];
|
||||
private lastId: string | undefined;
|
||||
private retry: number | undefined;
|
||||
|
||||
/** Decode one chunk, returning every frame it completed (possibly none). */
|
||||
feed(chunk: string): SseFrame[] {
|
||||
this.buffer += chunk;
|
||||
const frames: SseFrame[] = [];
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < this.buffer.length; i++) {
|
||||
const ch = this.buffer[i];
|
||||
if (ch !== '\n' && ch !== '\r') continue;
|
||||
// A trailing CR may be the first half of a CRLF the next chunk finishes.
|
||||
if (ch === '\r' && i === this.buffer.length - 1) break;
|
||||
const line = this.buffer.slice(start, i);
|
||||
if (ch === '\r' && this.buffer[i + 1] === '\n') i++;
|
||||
start = i + 1;
|
||||
const frame = this.consumeLine(line);
|
||||
if (frame) frames.push(frame);
|
||||
}
|
||||
|
||||
this.buffer = this.buffer.slice(start);
|
||||
if (this.buffer.length > MAX_PENDING_BYTES) this.reset();
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Drop every partial frame. Called when a connection is torn down. */
|
||||
reset(): void {
|
||||
this.buffer = '';
|
||||
this.eventName = '';
|
||||
this.dataLines = [];
|
||||
this.lastId = undefined;
|
||||
this.retry = undefined;
|
||||
}
|
||||
|
||||
private consumeLine(line: string): SseFrame | null {
|
||||
if (line === '') return this.dispatch();
|
||||
if (line.startsWith(':')) return null;
|
||||
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let value = colon === -1 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
|
||||
switch (field) {
|
||||
case 'event':
|
||||
this.eventName = value;
|
||||
break;
|
||||
case 'data':
|
||||
this.dataLines.push(value);
|
||||
break;
|
||||
case 'id':
|
||||
this.lastId = value;
|
||||
break;
|
||||
case 'retry': {
|
||||
const ms = Number.parseInt(value, 10);
|
||||
if (Number.isSafeInteger(ms) && ms >= 0) this.retry = ms;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank line ends a frame. Per the spec an empty data buffer dispatches
|
||||
* nothing (it still clears the event name), which is what makes a bare
|
||||
* `event:` line or a stray blank line harmless.
|
||||
*/
|
||||
private dispatch(): SseFrame | null {
|
||||
if (this.dataLines.length === 0) {
|
||||
this.eventName = '';
|
||||
return null;
|
||||
}
|
||||
const frame: SseFrame = {
|
||||
event: this.eventName || 'message',
|
||||
data: this.dataLines.join('\n'),
|
||||
};
|
||||
if (this.lastId !== undefined) frame.id = this.lastId;
|
||||
if (this.retry !== undefined) frame.retry = this.retry;
|
||||
this.eventName = '';
|
||||
this.dataLines = [];
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the app layer should do with a frame. */
|
||||
export type SseEventClass = 'init' | 'heartbeat' | 'resync' | 'approval' | 'plan-usage' | 'ignore';
|
||||
|
||||
/**
|
||||
* Events that change WHICH sessions exist or WHAT state they are in.
|
||||
*
|
||||
* The TUI never patches a single row from a payload: it re-fetches the unified
|
||||
* list, which is the only source that also carries history rows, so this set
|
||||
* only has to answer "is a refetch worth it". `session:terminal` is
|
||||
* deliberately absent (it is the bulk of the stream and the preview pane pulls
|
||||
* its own tail), as are the ralph/respawn/subagent/orchestrator families, which
|
||||
* change nothing the dashboard draws.
|
||||
*/
|
||||
const RESYNC_EVENTS: ReadonlySet<string> = new Set<string>([
|
||||
SessionCreated,
|
||||
SessionUpdated,
|
||||
SessionDeleted,
|
||||
SessionExit,
|
||||
SessionError,
|
||||
SessionIdle,
|
||||
SessionWorking,
|
||||
SessionCompletion,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionPinned,
|
||||
SessionCliInfo,
|
||||
MuxCreated,
|
||||
MuxKilled,
|
||||
MuxDied,
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
]);
|
||||
|
||||
const APPROVAL_EVENTS: ReadonlySet<string> = new Set<string>([ApprovalPending, ApprovalUpdated, ApprovalResolved]);
|
||||
|
||||
/** Which approval event this is, or null when the name is not one. */
|
||||
export function approvalEventKind(name: string): 'pending' | 'updated' | 'resolved' | null {
|
||||
if (name === ApprovalPending) return 'pending';
|
||||
if (name === ApprovalUpdated) return 'updated';
|
||||
if (name === ApprovalResolved) return 'resolved';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Route one event name. Unknown names are ignored, never a resync. */
|
||||
export function classifySseEvent(name: string): SseEventClass {
|
||||
if (name === Init) return 'init';
|
||||
if (name === Heartbeat) return 'heartbeat';
|
||||
if (APPROVAL_EVENTS.has(name)) return 'approval';
|
||||
if (name === SessionStatusTelemetry) return 'plan-usage';
|
||||
if (RESYNC_EVENTS.has(name)) return 'resync';
|
||||
return 'ignore';
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence that means the stream is dead even though the socket never errored.
|
||||
* The server heartbeats every 15s, so three missed beats is the signal.
|
||||
*/
|
||||
export const SSE_STALE_TIMEOUT_MS = 45_000;
|
||||
|
||||
/** Reconnect delay ceiling. A local server is back in milliseconds, not minutes. */
|
||||
export const SSE_MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
/** First reconnect delay; doubles per consecutive failure up to the ceiling. */
|
||||
export const SSE_BASE_BACKOFF_MS = 500;
|
||||
|
||||
/**
|
||||
* Delay before reconnect attempt `attempt` (1-based). Deterministic, with no
|
||||
* jitter on purpose: one client talks to one loopback server, so there is no
|
||||
* herd to spread out and a reproducible delay is testable.
|
||||
*/
|
||||
export function sseBackoffDelay(attempt: number, base = SSE_BASE_BACKOFF_MS, max = SSE_MAX_BACKOFF_MS): number {
|
||||
const step = Math.max(1, Math.trunc(attempt));
|
||||
const exponent = Math.min(step - 1, 30);
|
||||
return Math.min(max, base * 2 ** exponent);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @fileoverview Shared types for the `codeman tui` pure core.
|
||||
*
|
||||
* The TUI is a client of the server, never a second brain: its rows are the
|
||||
* rows `GET /api/sessions/unified` already returns (`UnifiedSessionItem`) and
|
||||
* its blocked states are the items `GET /api/approvals` already parsed
|
||||
* (`ApprovalItem`). Both are imported as TYPES only, so nothing here pulls the
|
||||
* server, node-pty or the utils barrel into a CLI process.
|
||||
*
|
||||
* Everything in `src/tui/*` except `tui-app.ts` / `tui-client.ts` is pure:
|
||||
* deterministic outputs from inputs, no `process.*`, no timers, no IO.
|
||||
*
|
||||
* @module tui/tui-types
|
||||
*/
|
||||
|
||||
import type { UnifiedSessionItem } from '../services/unified-session-service.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type { TuiComposerState } from './tui-composer.js';
|
||||
|
||||
/**
|
||||
* A unified-list row plus the few live-only extras the dashboard shows.
|
||||
*
|
||||
* The unified list is the spine (it is the only source that carries history
|
||||
* rows), but it has no token counters and no turn-start stamp, so the client
|
||||
* merges those from the live session payload (`GET /api/sessions` /
|
||||
* `session_updated` SSE) when a row is live. History rows simply lack them.
|
||||
*/
|
||||
export interface TuiSessionRow extends UnifiedSessionItem {
|
||||
/**
|
||||
* Wall-clock ms of the pane's last Enter (`SessionState.lastSubmitAt`). The
|
||||
* only usable "working since" anchor: a working pane repaints about once a
|
||||
* second, so its `lastActivityAt` is always "now".
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
/**
|
||||
* tmux session name to attach to (`codeman-<first 8 of the id>`).
|
||||
*
|
||||
* The unified list does not carry it (no server view merges the mux name into
|
||||
* a row), so the app layer fills it in from the local tmux enumeration, which
|
||||
* is also the only thing that proves the pane really exists. A row without one
|
||||
* cannot be attached: it is either history or a direct-PTY session.
|
||||
*/
|
||||
muxName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row state, in the web UI's vocabulary so both surfaces read the same.
|
||||
*
|
||||
* There is deliberately no `error` member: an errored session is something a
|
||||
* human has to look at, so it classifies as `waiting` and lands in NEEDS YOU
|
||||
* rather than growing a fifth color nobody designed.
|
||||
*/
|
||||
export type TuiSessionState = 'blocked-question' | 'blocked-permission' | 'waiting' | 'working' | 'idle' | 'recent';
|
||||
|
||||
/** The four display groups, in display order. */
|
||||
export type TuiGroupKey = 'needs-you' | 'working' | 'idle' | 'recent';
|
||||
|
||||
/** A classified session: what the cursor moves over and the renderer paints. */
|
||||
export interface TuiRow {
|
||||
session: TuiSessionRow;
|
||||
state: TuiSessionState;
|
||||
group: TuiGroupKey;
|
||||
/** The pending prompt that blocks this session, when it has one. */
|
||||
approval?: ApprovalItem;
|
||||
/** Epoch ms the session entered `state`; the intra-group sort key. 0 when unknown. */
|
||||
since: number;
|
||||
}
|
||||
|
||||
export interface TuiGroup {
|
||||
key: TuiGroupKey;
|
||||
label: string;
|
||||
rows: TuiRow[];
|
||||
}
|
||||
|
||||
/** How the client currently sees the server. */
|
||||
export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down';
|
||||
|
||||
/** Which overlay (if any) owns the keyboard. */
|
||||
export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'digest' | 'message' | 'new-session';
|
||||
|
||||
/**
|
||||
* Glyph capability tier. Detection is env-driven and therefore lives in a tiny
|
||||
* function the app layer calls (`detectGlyphTier`); the renderer only ever
|
||||
* takes the resolved tier as an input.
|
||||
*/
|
||||
export type TuiGlyphTier = 'nerd' | 'unicode' | 'ascii';
|
||||
|
||||
/** Header facts, all optional: the header degrades to just the product name. */
|
||||
export interface TuiHeaderInfo {
|
||||
hostname?: string;
|
||||
instance?: string;
|
||||
version?: string;
|
||||
/** Plan-usage chip text, e.g. `5h 32% · wk 61%`. */
|
||||
planUsage?: string;
|
||||
}
|
||||
|
||||
/** The selected session's terminal tail, already run through `toDisplayLines()`. */
|
||||
export interface TuiPreview {
|
||||
sessionId: string;
|
||||
/** Display lines, oldest first. */
|
||||
lines: string[];
|
||||
/** Set instead of lines when the tail could not be fetched. */
|
||||
error?: string;
|
||||
/**
|
||||
* Set instead of lines when there is nothing to fetch (a history row has no
|
||||
* live buffer). Distinct from `error`: nothing failed, so it must not read
|
||||
* like something did.
|
||||
*/
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TuiMessage {
|
||||
text: string;
|
||||
tone: 'info' | 'warn' | 'err';
|
||||
}
|
||||
|
||||
/** Typed-confirmation state for `x` (kill): the user retypes the session name. */
|
||||
export interface TuiConfirmState {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TuiPickerItem {
|
||||
/** What choosing this item means to the caller; never shown. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Second column, dimmed (a case path, a mode description). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-column chooser drawn as an overlay (the case and mode pickers behind
|
||||
* `n`). Items are already filtered: the app owns the unfiltered list, the
|
||||
* renderer only paints what it is given.
|
||||
*/
|
||||
export interface TuiPickerState {
|
||||
title: string;
|
||||
items: TuiPickerItem[];
|
||||
/** Index into `items`; -1 when the list is empty. */
|
||||
index: number;
|
||||
/** Current filter text, when the picker filters as you type. */
|
||||
filter?: string;
|
||||
/** One line above the list: what is being chosen, or why the list is empty. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** The `p` composer: one line aimed at one session. */
|
||||
export interface TuiPromptState {
|
||||
sessionId: string;
|
||||
/** What the session is called on screen, for the footer prefix. */
|
||||
label: string;
|
||||
composer: TuiComposerState;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of the `/` overlay. Group headers are chrome (the API returns typed
|
||||
* groups), so only `result` rows are selectable.
|
||||
*/
|
||||
export interface TuiSearchEntry {
|
||||
kind: 'header' | 'result';
|
||||
text: string;
|
||||
detail?: string;
|
||||
sessionId?: string;
|
||||
/** The row can hand the dashboard a session that is open right now. */
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
export interface TuiSearchState {
|
||||
composer: TuiComposerState;
|
||||
/** The query `entries` answer. Lags the composer while a search is in flight. */
|
||||
query: string;
|
||||
entries: TuiSearchEntry[];
|
||||
/** Index into `entries`, always a `result` row; -1 when none is selectable. */
|
||||
index: number;
|
||||
status: 'idle' | 'searching' | 'done' | 'error';
|
||||
/** One line under the query: what happened, or why there is nothing. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** The `g` overlay: pre-formatted lines plus where the window starts. */
|
||||
export interface TuiDigestState {
|
||||
title: string;
|
||||
lines: string[];
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `renderFrame()` reads. The store implements it; a test can hand-build
|
||||
* one, which is what keeps the renderer testable without the model.
|
||||
*/
|
||||
export interface TuiRenderModel {
|
||||
groups(): TuiGroup[];
|
||||
readonly selectedId: string | null;
|
||||
readonly connection: TuiConnectionStatus;
|
||||
readonly mode: TuiUiMode;
|
||||
readonly header: TuiHeaderInfo;
|
||||
readonly preview: TuiPreview | null;
|
||||
readonly message: TuiMessage | null;
|
||||
readonly confirm: TuiConfirmState | null;
|
||||
/** Optional so a test can hand-build a model without one. */
|
||||
readonly picker?: TuiPickerState | null;
|
||||
readonly prompt?: TuiPromptState | null;
|
||||
readonly search?: TuiSearchState | null;
|
||||
readonly digest?: TuiDigestState | null;
|
||||
/** Live sessions only (RECENT rows are history, not sessions you have open). */
|
||||
readonly sessionCount: number;
|
||||
}
|
||||
+5
-1
@@ -109,7 +109,11 @@ export type HookEventType =
|
||||
| 'elicitation_response'
|
||||
| 'stop'
|
||||
| 'teammate_idle'
|
||||
| 'task_completed';
|
||||
| 'task_completed'
|
||||
// No Claude Code hook behind this one: it is the DeepSeek status bridge's
|
||||
// "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with
|
||||
// HookEventSchema in web/schemas.ts.
|
||||
| 'agent_working';
|
||||
|
||||
// ========== API Response Types ==========
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { TaskState } from './task.js';
|
||||
import type { RalphLoopState } from './ralph.js';
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { CronJob, CronJobRun } from './cron.js';
|
||||
import type { TabLayout } from '../tab-layout.js';
|
||||
|
||||
// ========== Global Stats Types ==========
|
||||
|
||||
@@ -118,6 +119,8 @@ export interface AppState {
|
||||
cronJobRuns?: Record<string, CronJobRun>;
|
||||
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
|
||||
sessionOrder?: string[];
|
||||
/** Owner-scoped authoritative grouped tab layouts. */
|
||||
tabLayouts?: Record<string, TabLayout>;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
+111
-4
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -16,6 +16,8 @@
|
||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -44,11 +46,21 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi';
|
||||
export type SessionMode =
|
||||
| 'claude'
|
||||
| 'shell'
|
||||
| 'opencode'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp';
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -157,7 +169,7 @@ export interface RemoteSessionInfo {
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
@@ -332,6 +344,16 @@ export interface AntigravityConfig {
|
||||
resumeConversationId?: string;
|
||||
}
|
||||
|
||||
/** OMP CLI session configuration */
|
||||
export interface OmpConfig {
|
||||
/** Model identifier (e.g., "crof/glm-5.2"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Resume a previous conversation (passed via --resume). */
|
||||
resumeSessionId?: string;
|
||||
/** Continue the most recent session in this directory (passed via --continue). */
|
||||
continueSession?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi CLI (pi.dev) session configuration.
|
||||
*
|
||||
@@ -363,6 +385,85 @@ export interface PiConfig {
|
||||
approveProjectTrust?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok Build CLI (xAI `grok`) session configuration.
|
||||
*
|
||||
* Grok has Claude-style permission modes; the bypass switch is `--always-approve`
|
||||
* ("auto-approve all tool executions", the CLI's `bypassPermissions` mode). Deny
|
||||
* rules from `~/.grok/config.toml` / project `.grok/config.toml` still apply on
|
||||
* top of it. Verified against grok 1.0.5.
|
||||
*/
|
||||
export interface GrokConfig {
|
||||
/** Model ID (e.g. "grok-4.5", or a custom `[model.<name>]` from config.toml). Passed via --model. */
|
||||
model?: string;
|
||||
/**
|
||||
* Auto-approve all tool executions (passes --always-approve). Absent = grok's
|
||||
* own default permission mode (ask). Multi-user: forced off for non-granted
|
||||
* owners by the only-if-sent clamp branch, like codex/antigravity — the
|
||||
* absent-config spawn already defaults safe.
|
||||
*/
|
||||
alwaysApprove?: boolean;
|
||||
/** Continue the most recent session for the working directory (-c). Skipped when resumeSessionId is set. */
|
||||
continueSession?: boolean;
|
||||
/** Resume a specific session by ID (--resume). Ids only, never titles or paths. */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) session configuration.
|
||||
*
|
||||
* Two things make this config shaped unlike every sibling above it.
|
||||
*
|
||||
* **1. The agent is a PROFILE, not the binary.** `dsh` is a launcher: it boots
|
||||
* `$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers.
|
||||
* DeepSeek ships only `web`, `headless` and `base`, so the interactive terminal
|
||||
* agent is always a third-party profile the user installed. `profile` is
|
||||
* therefore the primary knob, and an absent one resolves to the first
|
||||
* pane-capable profile found (see resolveDefaultDeepSeekProfile).
|
||||
*
|
||||
* **2. Permissions are an ENV VAR, not a flag.** The harness has no
|
||||
* `--dangerously-skip-permissions` equivalent; its sandbox and approval rows are
|
||||
* config, driven by one documented input, `DSH_PERMISSION_MODE`, with three
|
||||
* presets (measured from `dsh --dump-default-config`):
|
||||
*
|
||||
* read-only sandbox read-only, approval ask
|
||||
* workspace-write sandbox workspace-write, approval ask <- default
|
||||
* danger-full-access sandbox danger-full-access, approval never
|
||||
*
|
||||
* This is the one place a Codeman env export is the RIGHT mechanism rather than
|
||||
* the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks
|
||||
* in-session `/effort`), `DSH_PERMISSION_MODE` is read with `??` as a boot-time
|
||||
* DEFAULT, so it stays a soft default the user can still change in-session. It
|
||||
* is exported via `tmux setenv`, never on the spawn command line.
|
||||
*/
|
||||
export interface DeepSeekConfig {
|
||||
/**
|
||||
* Profile under `$DSH_HOME/profiles` to boot (`dsh --profile <name>`). Absent
|
||||
* = the first pane-capable profile installed. A `web`/`headless` profile is
|
||||
* refused at spawn time: neither can drive an interactive pane.
|
||||
*/
|
||||
profile?: string;
|
||||
/**
|
||||
* Sandbox + approval preset, exported as `DSH_PERMISSION_MODE`. Absent = the
|
||||
* harness's own `workspace-write` default, which still ASKS — which is why the
|
||||
* multi-user clamp only needs the only-if-sent branch here, like
|
||||
* codex/antigravity/grok rather than pi.
|
||||
*/
|
||||
permissionMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
/** Resume the most recent session for this workspace (`--resume`). */
|
||||
resumeSession?: boolean;
|
||||
/** Resume a specific session by ID (`--resume <id>`). Wins over resumeSession. */
|
||||
resumeSessionId?: string;
|
||||
/**
|
||||
* Report idle/working/blocked back to Codeman through the Herdr-compatible
|
||||
* status shim (see `deepseek-status-shim.ts`). Default ON: it upgrades this
|
||||
* mode from output-stabilization guessing to definitive hook events. Only
|
||||
* TUIs that implement the contract report; for one that does not, this is
|
||||
* inert rather than harmful.
|
||||
*/
|
||||
statusReporting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -526,6 +627,12 @@ export interface SessionState {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Pi-specific configuration (only for mode === 'pi') */
|
||||
piConfig?: PiConfig;
|
||||
/** Grok-specific configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP-specific configuration (only for mode === 'omp') */
|
||||
ompConfig?: OmpConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
export type WebviewEmbedMode = 'proxy' | 'direct';
|
||||
|
||||
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
|
||||
/**
|
||||
* Dashboards Codeman creates and maintains on the user's behalf.
|
||||
*
|
||||
* A managed record is hidden from the saved-dashboard list, because the shortcut
|
||||
* that maintains it is already a menu entry of its own: listing both showed the
|
||||
* same dashboard twice, once as "DeepSeek web UI..." and once as the row it had
|
||||
* just written.
|
||||
*/
|
||||
export type WebviewManagedKind = 'deepseek-web';
|
||||
|
||||
export interface Webview {
|
||||
id: string;
|
||||
/** Display name shown on the tab. */
|
||||
@@ -49,6 +59,12 @@ export interface Webview {
|
||||
* cookies/localStorage, only for dashboards the user fully trusts.
|
||||
*/
|
||||
trusted: boolean;
|
||||
/**
|
||||
* Set when Codeman owns this record rather than the user (see
|
||||
* `WebviewManagedKind`). Managed rows are maintained by the shortcut that
|
||||
* created them, including repointing the URL when the port changes.
|
||||
*/
|
||||
managed?: WebviewManagedKind;
|
||||
/** Multi-user owner (username). Undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
|
||||
+44
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
|
||||
* @fileoverview Pure parsing + formatting of Claude and Codex plan telemetry.
|
||||
*
|
||||
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
|
||||
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
|
||||
@@ -15,7 +15,10 @@
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
|
||||
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
|
||||
* response and selects only the main `codex` bucket, excluding model-specific
|
||||
* buckets. All functions are pure for testability. See
|
||||
* `test/usage-telemetry.test.ts` and `test/codex-plan-usage.test.ts`.
|
||||
*
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
@@ -51,6 +54,22 @@ export interface RawStatuslinePayload {
|
||||
model?: { display_name?: string };
|
||||
}
|
||||
|
||||
interface RawCodexRateLimitWindow {
|
||||
usedPercent?: unknown;
|
||||
windowDurationMins?: unknown;
|
||||
resetsAt?: unknown;
|
||||
}
|
||||
|
||||
interface RawCodexRateLimitSnapshot {
|
||||
primary?: RawCodexRateLimitWindow | null;
|
||||
secondary?: RawCodexRateLimitWindow | null;
|
||||
}
|
||||
|
||||
interface RawCodexRateLimitsResponse {
|
||||
rateLimits?: RawCodexRateLimitSnapshot | null;
|
||||
rateLimitsByLimitId?: Record<string, RawCodexRateLimitSnapshot | null> | null;
|
||||
}
|
||||
|
||||
function clampPct(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
@@ -88,6 +107,29 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
|
||||
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const response = value as RawCodexRateLimitsResponse;
|
||||
const snapshot = response.rateLimitsByLimitId?.codex ?? response.rateLimits;
|
||||
if (!snapshot || typeof snapshot !== 'object') return null;
|
||||
|
||||
const telemetry: StatusTelemetry = {};
|
||||
for (const window of [snapshot.primary, snapshot.secondary]) {
|
||||
if (!window || typeof window.usedPercent !== 'number' || !Number.isFinite(window.usedPercent)) continue;
|
||||
if (window.windowDurationMins !== 300 && window.windowDurationMins !== 10_080) continue;
|
||||
const resetsAt =
|
||||
typeof window.resetsAt === 'number' && Number.isFinite(window.resetsAt) && window.resetsAt > 0
|
||||
? Math.round(window.resetsAt * 1000)
|
||||
: 0;
|
||||
const normalized = { usedPercentage: clampPct(window.usedPercent), resetAt: resetsAt };
|
||||
if (window.windowDurationMins === 300) telemetry.fiveHour = normalized;
|
||||
if (window.windowDurationMins === 10_080) telemetry.sevenDay = normalized;
|
||||
}
|
||||
|
||||
return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current-session status for the in-terminal statusline footer. This is the
|
||||
* "status of the current session" the user sees in Claude's footer — distinct
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
|
||||
*
|
||||
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
|
||||
* codex, gemini, antigravity, pi): the server process PATH first, then the
|
||||
* One lookup chain behind all seven *-cli-resolver modules (claude, opencode,
|
||||
* codex, gemini, antigravity, pi, grok): the server process PATH first, then the
|
||||
* CLI's common install directories in order, then — last, because it is the
|
||||
* only step that spawns anything — an interactive login shell, which is what
|
||||
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
import { parseCodexRateLimitsResponse, type StatusTelemetry } from '../usage-telemetry.js';
|
||||
|
||||
/** Common directories where the Codex CLI binary may be installed */
|
||||
const CODEX_SEARCH_DIRS = [
|
||||
@@ -21,7 +23,8 @@ const CODEX_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
|
||||
const CODEX_BINARY = process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||
const codexResolver = createCliExecutableResolver({ binary: CODEX_BINARY, searchDirs: CODEX_SEARCH_DIRS });
|
||||
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||
|
||||
/**
|
||||
@@ -35,6 +38,11 @@ export function resolveCodexDir(): string | null {
|
||||
return codexResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/** Absolute Codex executable path, for direct app-server requests. */
|
||||
export function resolveCodexBinaryPath(): string | null {
|
||||
return codexResolver.resolve()?.binaryPath ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system.
|
||||
*/
|
||||
@@ -45,3 +53,80 @@ export function isCodexAvailable(): boolean {
|
||||
export function getCodexNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
||||
}
|
||||
|
||||
type CodexRateLimitsRequest = (binaryPath: string, clientVersion: string) => Promise<unknown>;
|
||||
|
||||
const APP_SERVER_TIMEOUT_MS = 10_000;
|
||||
const APP_SERVER_MAX_OUTPUT_BYTES = 256 * 1024;
|
||||
|
||||
function requestCodexRateLimits(binaryPath: string, clientVersion: string): Promise<unknown> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let initialized = false;
|
||||
let buffer = '';
|
||||
const child = spawn(binaryPath, ['app-server', '--stdio'], {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const timeout = setTimeout(() => finish(null), APP_SERVER_TIMEOUT_MS);
|
||||
|
||||
const finish = (value: unknown): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
child.stdin.end();
|
||||
child.kill();
|
||||
resolve(value);
|
||||
};
|
||||
const send = (message: unknown): void => {
|
||||
if (!settled && child.stdin.writable) child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
const handleLine = (line: string): void => {
|
||||
if (!line.trim()) return;
|
||||
let message: { id?: number; result?: unknown; error?: unknown };
|
||||
try {
|
||||
message = JSON.parse(line) as { id?: number; result?: unknown; error?: unknown };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.id === 1) {
|
||||
if (message.error) return finish(null);
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
send({ method: 'account/rateLimits/read', id: 2 });
|
||||
}
|
||||
} else if (message.id === 2) {
|
||||
finish(message.error ? null : message.result);
|
||||
}
|
||||
};
|
||||
|
||||
child.on('error', () => finish(null));
|
||||
child.on('close', () => finish(null));
|
||||
child.stdin.on('error', () => finish(null));
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
if (Buffer.byteLength(buffer) > APP_SERVER_MAX_OUTPUT_BYTES) return finish(null);
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) handleLine(line);
|
||||
});
|
||||
|
||||
send({
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: {
|
||||
clientInfo: { name: 'codeman', title: 'Codeman', version: clientVersion },
|
||||
capabilities: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the signed-in host account's main Codex limits without exposing credentials. */
|
||||
export async function readCodexPlanUsage(
|
||||
binaryPath: string,
|
||||
clientVersion: string,
|
||||
request: CodexRateLimitsRequest = requestCodexRateLimits
|
||||
): Promise<StatusTelemetry | null> {
|
||||
return parseCodexRateLimitsResponse(await request(binaryPath, clientVersion));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles.
|
||||
*
|
||||
* Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here
|
||||
* is STRICTER than either, and deliberately so: `dsh` is not merely a short name
|
||||
* with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and
|
||||
* Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`),
|
||||
* which like nearly every Unix tool prints a version-shaped string of its own.
|
||||
* A version-token probe alone (which is all pi and grok need) would
|
||||
* therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn
|
||||
* line, so every candidate must additionally prove its identity by printing the
|
||||
* harness's own help banner.
|
||||
*
|
||||
* Two probes per candidate, both bounded and both cached behind the shared
|
||||
* resolver's positive/negative caching:
|
||||
* 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`)
|
||||
* 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`)
|
||||
* Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the
|
||||
* cheaper, more discriminating signal and never contributes a version number.
|
||||
*
|
||||
* `dsh` is a profile LAUNCHER, not an agent: `dsh --profile <name>` boots an
|
||||
* ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web`
|
||||
* (browser UI), `headless` (one-shot) and `base` (no app). The interactive
|
||||
* terminal agent Codeman actually drives is a THIRD-PARTY profile the user
|
||||
* installs. That is why this module resolves two independent things — a binary
|
||||
* AND a profile inventory — and why "available" for the deepseek run mode means
|
||||
* both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts
|
||||
* for the actionable per-half message).
|
||||
*
|
||||
* @module utils/deepseek-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/**
|
||||
* Common directories where the `dsh` binary may be installed.
|
||||
*
|
||||
* `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no
|
||||
* vendor-owned install dir to lead with: the global npm bin is wherever the
|
||||
* user's prefix points. `~/.local/bin` heads the list because it is the default
|
||||
* for a prefix-relocated npm (and is where this box's install landed).
|
||||
*/
|
||||
const DEEPSEEK_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/**
|
||||
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
|
||||
* the prerelease suffix is part of the token — truncating it to `0.1.1` would
|
||||
* misreport a release-candidate as a release in `codeman doctor`.
|
||||
*
|
||||
* Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`,
|
||||
* so the doctor and the run mode cannot disagree about what counts as an
|
||||
* installed dsh (the same single-source rule as PI_VERSION_REGEX /
|
||||
* GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()`
|
||||
* (first capture group, whole-output scan): hence a capturing group and a
|
||||
* leading boundary instead of `^`. No `g` flag, so there is no shared
|
||||
* `lastIndex` to reset.
|
||||
*/
|
||||
export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/;
|
||||
|
||||
/**
|
||||
* The identity marker that separates DeepSeek's `dsh` from Debian's dancer's
|
||||
* shell. The real launcher's `--help` banner reads:
|
||||
*
|
||||
* dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle …
|
||||
*
|
||||
* Matched case-insensitively against the help output. This is the check that
|
||||
* makes the resolver safe to point a spawn line at; see the module header.
|
||||
*/
|
||||
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
|
||||
|
||||
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
|
||||
|
||||
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
|
||||
export function resolveDshHome(): string {
|
||||
const fromEnv = process.env.DSH_HOME?.trim();
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh');
|
||||
}
|
||||
|
||||
/**
|
||||
* What a profile is FOR, inferred from the bundles it composes.
|
||||
*
|
||||
* `interactive` is the only kind a tmux pane can drive: `web` serves a browser
|
||||
* UI and would occupy the pane with a logging server, `headless` answers one
|
||||
* task and exits (which reads as an instantly-dead pane). `unknown` is treated
|
||||
* as interactive-capable on purpose — the whole point of the harness is that
|
||||
* anyone can publish an app bundle, so an unrecognized third-party profile must
|
||||
* not be hidden from the picker just because this list has not heard of it.
|
||||
*/
|
||||
export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown';
|
||||
|
||||
export interface DeepSeekProfile {
|
||||
/** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */
|
||||
name: string;
|
||||
/** Bundle package names composed by the profile, in order. */
|
||||
bundles: string[];
|
||||
kind: DeepSeekProfileKind;
|
||||
}
|
||||
|
||||
/** Bundles that positively identify a non-interactive profile. */
|
||||
const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i;
|
||||
const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i;
|
||||
/**
|
||||
* Bundles that positively identify a terminal app. Intentionally a loose
|
||||
* community-wide pattern rather than one blessed package: the terminal front
|
||||
* door is third-party by construction (DeepSeek ships none), and a dozen
|
||||
* scoped `dsh-tui` packages from a dozen different authors compete. Anything
|
||||
* matching is a TUI; anything unmatched is `unknown`, which still counts as
|
||||
* launchable.
|
||||
*
|
||||
* `tui` carries word boundaries so the loose arm stays a TOKEN match: `-` and
|
||||
* `/` are non-word characters, so `@someone/tui-app` and `dsh-tui` both match
|
||||
* while `intuition` and `gratuitous` do not. Being wrong here is cheap (an
|
||||
* unmatched profile is `unknown`, which is launchable too) but it decides which
|
||||
* profile a session boots by DEFAULT, and "the one whose name happens to contain
|
||||
* t-u-i" is not a rule anyone could predict.
|
||||
*/
|
||||
const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|\btui\b/i;
|
||||
|
||||
/**
|
||||
* The profile names DeepSeek itself ships for its non-interactive surfaces.
|
||||
*
|
||||
* Consulted only AFTER the bundle patterns have found nothing, and only against
|
||||
* the directory name. `readProfile()` yields an empty bundle list for any
|
||||
* `package.json` without a `dsh.profile.bundles` array — a hand-edited file, an
|
||||
* older layout, a profile mid-install — and with no bundles to read, the stock
|
||||
* `web` and `headless` profiles look exactly like an unrecognized third-party
|
||||
* one and inherit its launchable-by-default treatment. That is the single
|
||||
* "unknown" that is knowably wrong, and it produces precisely the
|
||||
* pane-dies-on-arrival failure the two-part availability gate exists to prevent.
|
||||
*
|
||||
* Deliberately a fallback rather than a first check: a third-party profile that
|
||||
* legitimately composes a terminal app is identified by its BUNDLES, and its
|
||||
* directory name (which the user chose) must never override that evidence.
|
||||
*/
|
||||
const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
||||
['web', 'web'],
|
||||
['headless', 'headless'],
|
||||
]);
|
||||
|
||||
/** Profile directory names that are not profiles. */
|
||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||
|
||||
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||
const haystack = [name, ...bundles].join(' ');
|
||||
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||
// web profile as far as a tmux pane is concerned, because the web app owns the
|
||||
// process and blocks.
|
||||
if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web';
|
||||
if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless';
|
||||
if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive';
|
||||
return STOCK_NON_INTERACTIVE_PROFILES.get(name.toLowerCase()) ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single profile directory's `package.json` and return its bundle list.
|
||||
* Returns null for anything that is not a readable dsh profile, so a stray
|
||||
* directory under `profiles/` cannot break the inventory.
|
||||
*/
|
||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||
try {
|
||||
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||
return { name, bundles, kind: classifyProfile(name, bundles) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory the profiles installed under `$DSH_HOME/profiles`.
|
||||
*
|
||||
* Never throws: a missing DSH_HOME (dsh installed but never run) is an empty
|
||||
* list, which the callers render as "no profile yet" rather than an error.
|
||||
* Deliberately un-cached — a user can create a profile at any moment (including
|
||||
* through Codeman's own bootstrap), and the directory scan is cheap next to the
|
||||
* two process spawns the binary probe already costs.
|
||||
*/
|
||||
export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
||||
const profilesDir = join(resolveDshHome(), 'profiles');
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((name) => readProfile(profilesDir, name))
|
||||
.filter((p): p is DeepSeekProfile => p !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a session should boot when the user picked none.
|
||||
*
|
||||
* Prefers a positively-identified terminal profile, then an unrecognized one
|
||||
* (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall
|
||||
* back to `web`/`headless`, which cannot drive a pane. Returns null when nothing
|
||||
* launchable is installed, which is what makes the mode report unavailable
|
||||
* instead of spawning a pane that dies on arrival.
|
||||
*/
|
||||
export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null {
|
||||
return (
|
||||
profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the profile can occupy a tmux pane as an interactive agent. */
|
||||
export function isLaunchableProfile(profile: DeepSeekProfile): boolean {
|
||||
return profile.kind === 'interactive' || profile.kind === 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the two-stage identity+version probe on a candidate path.
|
||||
*
|
||||
* Returns the version token only when the binary proves it is the DeepSeek
|
||||
* Harness launcher. Returns null for anything else: a missing binary, a
|
||||
* non-zero exit, a hang (timeout), a help banner without the harness marker
|
||||
* (this is the dancer's-shell rejection), or output with no version-shaped
|
||||
* token.
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have dsh installed — and since `dsh` names a
|
||||
* real Debian program, this probe would EXECUTE whatever binary of that name the
|
||||
* machine carries. The shared resolver host is already inert under vitest, so
|
||||
* this gate is defense in depth for any opted-in host that still carries the
|
||||
* default probe; tests drive resolution via `createDeepSeekResolverForTest`,
|
||||
* whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts.
|
||||
*/
|
||||
function probeDeepSeekVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
const run = (args: string[]): string | null => {
|
||||
try {
|
||||
return execFileSync(binPath, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Identity first — the discriminating signal, and the one that keeps Debian's
|
||||
// dancer's shell out of a spawn line.
|
||||
const help = run(['--help']);
|
||||
if (help === null) return null;
|
||||
if (!DEEPSEEK_IDENTITY_REGEX.test(help)) {
|
||||
console.warn(
|
||||
`[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` +
|
||||
`(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` +
|
||||
`dancer's shell) is earlier on PATH.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const out = run(['--version']);
|
||||
if (out === null) return null;
|
||||
const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
type DeepSeekVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createDeepSeekResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'dsh',
|
||||
searchDirs: DEEPSEEK_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated DeepSeek wrapper around an injected host, version probe
|
||||
* and clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe,
|
||||
* which is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createDeepSeekResolverForTest(
|
||||
host: CliResolverHost,
|
||||
versionProbe?: DeepSeekVersionProbe,
|
||||
now?: () => number
|
||||
) {
|
||||
return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now);
|
||||
}
|
||||
|
||||
const deepSeekResolver = createDeepSeekResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `dsh` binary.
|
||||
* Checks the server PATH first, then the common install locations. Every
|
||||
* candidate must pass the identity+version probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveDeepSeekDir(): string | null {
|
||||
return deepSeekResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `dsh` BINARY is installed. Note this is deliberately weaker than
|
||||
* what the run mode needs: a dsh with no launchable profile cannot start a
|
||||
* session. Callers gating the Run button want `isDeepSeekRunnable()`.
|
||||
*/
|
||||
export function isDeepSeekAvailable(): boolean {
|
||||
return resolveDeepSeekDir() !== null;
|
||||
}
|
||||
|
||||
/** Binary present AND at least one profile that can occupy a pane. */
|
||||
export function isDeepSeekRunnable(): boolean {
|
||||
return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null;
|
||||
}
|
||||
|
||||
export function getDeepSeekNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `dsh` binary, or null when dsh is
|
||||
* unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution
|
||||
* is diagnosable from the UI.
|
||||
*/
|
||||
export function getDeepSeekCliVersion(): string | null {
|
||||
return deepSeekResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
|
||||
/** Does the named profile exist and can it drive a pane? */
|
||||
export function profileExists(name: string): boolean {
|
||||
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a DeepSeek session cannot start, or null when it can.
|
||||
*
|
||||
* Availability for this mode is TWO questions, not one, because `dsh` is a
|
||||
* profile launcher rather than an agent: the binary must resolve (and prove it
|
||||
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
|
||||
* a pane must exist. Every create path — both HTTP routes AND cron fires — must
|
||||
* ask this before constructing a Session, or the pane boots the box's default
|
||||
* profile, which may be a logging web server or a one-shot that exits on
|
||||
* arrival, and the prompt is typed into it.
|
||||
*/
|
||||
export function resolveDeepSeekLaunchError(requestedProfile?: string): string | null {
|
||||
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
|
||||
|
||||
const profiles = listDeepSeekProfiles();
|
||||
if (requestedProfile) {
|
||||
const match = profiles.find((p) => p.name === requestedProfile);
|
||||
if (!match) {
|
||||
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
|
||||
}
|
||||
if (match.kind === 'web' || match.kind === 'headless') {
|
||||
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resolveDefaultDeepSeekProfile(profiles)) {
|
||||
return (
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin — install one with: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,17 +1,50 @@
|
||||
/**
|
||||
* @fileoverview Renders ToolResult[] from the dependency checker into a
|
||||
* human-readable grouped table or JSON, and computes the process exit code.
|
||||
* Plain text only (no color) so output is stable and snapshot-friendly; the
|
||||
* CLI layer may colorize.
|
||||
* Plain text by default (no color) so output is stable and snapshot-friendly;
|
||||
* the CLI layer passes a `ReportStyle` to paint it (see `cli.ts`, `doctor`).
|
||||
*
|
||||
* Column widths are measured, not hardcoded: "Antigravity CLI" is 15 characters
|
||||
* and the old `padEnd(14)` pushed its whole row one column right.
|
||||
*
|
||||
* @module utils/dependency-report
|
||||
*/
|
||||
|
||||
import { columnWidths, padStyled } from '../cli-style.js';
|
||||
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
|
||||
import type { ToolResult, ToolStatus } from './dependency-checker.js';
|
||||
|
||||
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
|
||||
|
||||
/**
|
||||
* Paint hooks for the CLI layer. Every hook is identity by default, so this
|
||||
* module never decides anything about color and its output stays byte-stable
|
||||
* for tests.
|
||||
*/
|
||||
export interface ReportStyle {
|
||||
title(text: string): string;
|
||||
heading(text: string): string;
|
||||
glyph(result: ToolResult, glyph: string): string;
|
||||
label(text: string): string;
|
||||
status(result: ToolResult, text: string): string;
|
||||
path(text: string): string;
|
||||
meta(text: string): string;
|
||||
summary(text: string): string;
|
||||
}
|
||||
|
||||
const identity = (text: string): string => text;
|
||||
|
||||
const PLAIN_STYLE: ReportStyle = {
|
||||
title: identity,
|
||||
heading: identity,
|
||||
glyph: (_result, glyph) => glyph,
|
||||
label: identity,
|
||||
status: (_result, text) => text,
|
||||
path: identity,
|
||||
meta: identity,
|
||||
summary: identity,
|
||||
};
|
||||
|
||||
function glyph(r: ToolResult): string {
|
||||
if (r.status === 'ok') return '✓';
|
||||
if (r.status === 'skipped') return '○';
|
||||
@@ -33,24 +66,34 @@ export function computeExitCode(results: ToolResult[]): number {
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
|
||||
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
|
||||
export function renderTable(
|
||||
results: ToolResult[],
|
||||
environment: ProbeEnvironment,
|
||||
style: ReportStyle = PLAIN_STYLE
|
||||
): string {
|
||||
// Widths are taken across ALL categories so the groups line up with each other.
|
||||
const [labelWidth, statusWidth] = columnWidths(results.map((r) => [r.label, statusText(r)]));
|
||||
const lines: string[] = [style.title(`Codeman dependency check — ${environment}`), ''];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const rows = results.filter((r) => r.category === category);
|
||||
if (rows.length === 0) continue;
|
||||
lines.push(category.toUpperCase());
|
||||
lines.push(style.heading(category.toUpperCase()));
|
||||
for (const r of rows) {
|
||||
const detail = r.path ? ` ${r.path}` : '';
|
||||
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
|
||||
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
|
||||
if (r.installHint) lines.push(` install: ${r.installHint}`);
|
||||
const label = padStyled(r.label, labelWidth ?? 0, style.label);
|
||||
const status = padStyled(statusText(r), statusWidth ?? 0, (text) => style.status(r, text));
|
||||
const detail = r.path ? ` ${style.path(r.path)}` : '';
|
||||
lines.push(` ${style.glyph(r, glyph(r))} ${label} ${status}${detail}`.trimEnd());
|
||||
if (r.usedBy.length) lines.push(style.meta(` used by: ${r.usedBy.join(', ')}`));
|
||||
if (r.installHint) lines.push(style.meta(` install: ${r.installHint}`));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
const ok = results.filter((r) => r.status === 'ok').length;
|
||||
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
|
||||
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
|
||||
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
|
||||
lines.push(
|
||||
style.summary(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`)
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Grok Build CLI (`grok`, xAI) binary across common install paths.
|
||||
*
|
||||
* Mirrors pi-cli-resolver.ts, version probe included: `grok` is another short
|
||||
* name with known squatters (the unrelated `@vibe-kit/grok-cli` npm package also
|
||||
* installs a `grok` bin), so a `which grok` hit is not by itself evidence that
|
||||
* xAI's coding agent is installed. Every candidate is sanity-probed with
|
||||
* `grok --version` and required to print a version-shaped string (the real CLI
|
||||
* prints `grok 1.0.5 (5115b46bc9)`); a binary that fails the probe is treated
|
||||
* as absent and the rejected path is logged. The probe cannot tell two
|
||||
* version-printing `grok`s apart, which is why `GET /api/grok/status` surfaces
|
||||
* path AND version: a misresolution is diagnosable rather than presenting as
|
||||
* "the mode just doesn't work".
|
||||
*
|
||||
* The official installer (`curl -fsSL https://x.ai/cli/install.sh | bash`)
|
||||
* places the binary in `~/.grok/bin` and symlinks it into `~/.local/bin`, so
|
||||
* those two head the search list.
|
||||
*
|
||||
* @module utils/grok-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Grok CLI binary may be installed */
|
||||
const GROK_SEARCH_DIRS = [
|
||||
join(homedir(), '.grok', 'bin'),
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/**
|
||||
* A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5).
|
||||
*
|
||||
* Exported and SHARED with the `grok` entry in `config/dependency-registry.ts`,
|
||||
* so `codeman doctor` and the run mode cannot disagree about what counts as an
|
||||
* installed grok (the same single-source rule as PI_VERSION_REGEX). Shape is
|
||||
* dictated by the doctor's `extractVersion()` (first capture group, whole-output
|
||||
* scan): hence a capturing group and a leading boundary instead of `^`. No `g`
|
||||
* flag, so there is no shared `lastIndex` to reset.
|
||||
*/
|
||||
export const GROK_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
||||
|
||||
const GROK_NOT_FOUND = 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash';
|
||||
|
||||
/**
|
||||
* Run `grok --version` on a candidate path and return the version token when it
|
||||
* looks like the coding agent. Returns null for anything else: a missing
|
||||
* binary, a non-zero exit, a hang (timeout), or output with no version-shaped
|
||||
* token (which is how an unrelated `grok` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have grok installed, and since `grok` is a
|
||||
* name with known squatters, this probe would EXECUTE whatever binary of that
|
||||
* name the machine carries. The shared resolver host is already inert under
|
||||
* vitest, so this gate is defense in depth for any opted-in host that still
|
||||
* carries the default probe; tests drive resolution via
|
||||
* `createGrokResolverForTest`, whose injected probe bypasses it. Pinned by
|
||||
* test/grok-cli-resolver.test.ts.
|
||||
*/
|
||||
function probeGrokVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
try {
|
||||
const out = execFileSync(binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `grok` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const candidate = GROK_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||
} catch (err) {
|
||||
console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" failed (${(err as Error).message})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type GrokVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createGrokResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: GrokVersionProbe = probeGrokVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'grok',
|
||||
searchDirs: GROK_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated Grok wrapper around an injected host, version probe and
|
||||
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||
* is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createGrokResolverForTest(host: CliResolverHost, versionProbe?: GrokVersionProbe, now?: () => number) {
|
||||
return createGrokResolver(host, versionProbe ?? probeGrokVersion, now);
|
||||
}
|
||||
|
||||
const grokResolver = createGrokResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `grok` binary.
|
||||
* Checks the server PATH first, then the common install locations
|
||||
* (`~/.grok/bin` leading, the official installer's target). Every candidate
|
||||
* must pass the `grok --version` sanity probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveGrokDir(): string | null {
|
||||
return grokResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Grok CLI is available on the system.
|
||||
*/
|
||||
export function isGrokAvailable(): boolean {
|
||||
return resolveGrokDir() !== null;
|
||||
}
|
||||
|
||||
export function getGrokNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(GROK_NOT_FOUND, grokResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `grok` binary, or null when grok is
|
||||
* unavailable. Surfaced through `GET /api/grok/status` so a misresolution is
|
||||
* diagnosable from the UI.
|
||||
*/
|
||||
export function getGrokCliVersion(): string | null {
|
||||
return grokResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
+22
-1
@@ -37,7 +37,13 @@ export {
|
||||
} from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
|
||||
export {
|
||||
resolveCodexDir,
|
||||
resolveCodexBinaryPath,
|
||||
isCodexAvailable,
|
||||
getCodexNotFoundMessage,
|
||||
readCodexPlanUsage,
|
||||
} from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||
export {
|
||||
resolveAntigravityDir,
|
||||
@@ -45,5 +51,20 @@ export {
|
||||
getAntigravityNotFoundMessage,
|
||||
} from './antigravity-cli-resolver.js';
|
||||
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
|
||||
export {
|
||||
resolveDeepSeekDir,
|
||||
isDeepSeekAvailable,
|
||||
isDeepSeekRunnable,
|
||||
getDeepSeekCliVersion,
|
||||
getDeepSeekNotFoundMessage,
|
||||
listDeepSeekProfiles,
|
||||
resolveDefaultDeepSeekProfile,
|
||||
isLaunchableProfile,
|
||||
resolveDshHome,
|
||||
profileExists,
|
||||
} from './deepseek-cli-resolver.js';
|
||||
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview Resolve the OMP CLI binary across common install paths.
|
||||
*
|
||||
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
|
||||
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
|
||||
* server process PATH first, then common install directories, then — last,
|
||||
* because it is the only step that spawns anything — an interactive login
|
||||
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
|
||||
* as a systemd/launchd service with a minimal PATH.
|
||||
*
|
||||
* Provides an augmented PATH directory for tmux sessions.
|
||||
*
|
||||
* @module utils/omp-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/**
|
||||
* Common directories where the OMP CLI binary may be installed. `~/.local/bin`
|
||||
* leads: omp.sh's installer targets `$HOME/.local/bin` with no `--dir`
|
||||
* override (verified against a real `--no-cache` Docker build — see
|
||||
* docker/agent.Dockerfile); `~/.omp/bin` was an unverified guess that turned
|
||||
* out wrong, kept after `~/.local/bin` only as a defensive fallback.
|
||||
*/
|
||||
const OMP_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
join(homedir(), '.omp', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/**
|
||||
* A real `omp --version` prints `omp/<semver>` (e.g. `omp/17.4.0`).
|
||||
*
|
||||
* Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so
|
||||
* `omp/17.4.0` matches while an unrelated `omp` (some other program) does not.
|
||||
*/
|
||||
export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/;
|
||||
|
||||
const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh';
|
||||
|
||||
/**
|
||||
* Run `omp --version` on a candidate path and return the trimmed version when
|
||||
* it looks like the coding agent. Returns null for anything else — a missing
|
||||
* binary, a non-zero exit, a hang (timeout), or output that is not
|
||||
* `omp/<semver>`-shaped (which is how an unrelated `omp` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have omp installed. The shared resolver host
|
||||
* is already inert under vitest, so this gate is defense in depth for any
|
||||
* opted-in host that still carries the default probe.
|
||||
*/
|
||||
function probeOmpVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
try {
|
||||
const out = execFileSync(binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `omp` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const candidate = OMP_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||
} catch (err) {
|
||||
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type OmpVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createOmpResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: OmpVersionProbe = probeOmpVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'omp',
|
||||
searchDirs: OMP_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated OMP wrapper around an injected host, version probe and
|
||||
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||
* is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) {
|
||||
return createOmpResolver(host, versionProbe ?? probeOmpVersion, now);
|
||||
}
|
||||
|
||||
const ompResolver = createOmpResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `omp` binary.
|
||||
* Checks `which omp` first, then falls back to common install locations. Every
|
||||
* candidate must pass the `omp --version` sanity probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveOmpDir(): string | null {
|
||||
return ompResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the OMP CLI is available on the system.
|
||||
*/
|
||||
export function isOmpAvailable(): boolean {
|
||||
return resolveOmpDir() !== null;
|
||||
}
|
||||
|
||||
export function getOmpNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `omp` binary, or null when omp is
|
||||
* unavailable. Surfaced through `GET /api/omp/status` so a misresolution is
|
||||
* diagnosable from the UI.
|
||||
*/
|
||||
export function getOmpCliVersion(): string | null {
|
||||
return ompResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @fileoverview Resolve the real OMP session id for a working directory, so a
|
||||
* relaunch can pass `--resume <id>` instead of the ambiguous `--continue`.
|
||||
*
|
||||
* `omp` persists each conversation as its own file under
|
||||
* `~/.omp/agent/sessions/<mangled-workingDir>/<ISO-timestamp>_<session-uuid>.jsonl`
|
||||
* (workingDir mangled the same way Claude Code mangles `~/.claude/projects/*`:
|
||||
* every `/` replaced with `-`). `--continue` picks whichever file in that
|
||||
* directory is newest, which silently drifts to the WRONG conversation the
|
||||
* moment two Codeman sessions ever touch the same directory — exactly what a
|
||||
* closed-then-resumed row plus a still-running duplicate produces. Resolving
|
||||
* the id once and pinning it with `--resume` removes that ambiguity for every
|
||||
* later relaunch of the same Codeman session.
|
||||
*
|
||||
* @module utils/omp-session-resolver
|
||||
*/
|
||||
|
||||
import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
/** A real OMP session file is `<ISO-ish-timestamp>_<uuid>.jsonl`; only the uuid matters here. */
|
||||
const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/;
|
||||
|
||||
/**
|
||||
* Mirrors `omp`'s own directory mangling. Confirmed empirically against real
|
||||
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
|
||||
* `~/.claude/projects/*`, which keeps the home prefix (`-home-user-dev-foo`),
|
||||
* omp collapses a home-relative workingDir to its home-relative remainder
|
||||
* FIRST (`/home/user/dev/foo` -> `/dev/foo`) and only then dash-replaces
|
||||
* (`-dev-foo`) — a path outside $HOME (e.g. `/tmp/...`) is dash-replaced as-is.
|
||||
* Getting this wrong doesn't error, it just silently returns an empty
|
||||
* directory listing: findLatestOmpSessionId() below then always falls through
|
||||
* to null, so continuation pinning quietly degrades to omp's own ambiguous
|
||||
* `--continue` for every case under $HOME (i.e. virtually all real Codeman
|
||||
* cases) while appearing to work in `/tmp`-based manual testing.
|
||||
* Pure so it's unit-testable without touching the filesystem.
|
||||
*/
|
||||
export function mangleOmpWorkingDir(workingDir: string): string {
|
||||
// UNVERIFIED EDGE CASE: if $HOME is itself a symlink, this compares against
|
||||
// the literal homedir() string, not a realpath()-resolved one. Whether that
|
||||
// matches omp's own behavior is unconfirmed — we only empirically verified
|
||||
// omp strips a literal $HOME prefix (2026-08-27), not that it canonicalizes
|
||||
// symlinks first. Do not "fix" this with realpathSync() without confirming
|
||||
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
|
||||
// trade one silent mismatch for a different one.
|
||||
const home = homedir();
|
||||
const relative =
|
||||
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
|
||||
return relative.replace(/\//g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* `~/.omp` — omp's own env overrides are mostly `PI_*` (shared with pi mode, already
|
||||
* allowlisted in schemas.ts), and `PI_CONFIG_DIR` in particular can move this root.
|
||||
* That is not honored here: a session with a redirected `PI_CONFIG_DIR` silently
|
||||
* degrades pinning/history to omp's own ambiguous `--continue` instead of erroring,
|
||||
* a known gap (found in Ark0N/Codeman#353 review) shared with pi and not fixed here.
|
||||
*/
|
||||
function resolveOmpHome(): string {
|
||||
return join(homedir(), '.omp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest OMP session id for this working directory, or null when the
|
||||
* directory doesn't exist yet (never launched) or holds no session files.
|
||||
*
|
||||
* Deliberately "newest file, full stop" rather than a time-windowed match:
|
||||
* callers only invoke this at a moment where that's unambiguous by
|
||||
* construction — right after the file that answers it was the only thing
|
||||
* that could have just been written (a dead pane's process already exited,
|
||||
* or a session being resumed has no live sibling in the same directory yet).
|
||||
*/
|
||||
export function findLatestOmpSessionId(workingDir: string): string | null {
|
||||
const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir));
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newestMtime = -Infinity;
|
||||
let newestId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
const match = OMP_SESSION_FILE_PATTERN.exec(entry);
|
||||
if (!match) continue;
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = statSync(join(dir, entry)).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (mtimeMs > newestMtime) {
|
||||
newestMtime = mtimeMs;
|
||||
newestId = match[1];
|
||||
}
|
||||
}
|
||||
return newestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session header line is always near the top of the file (the
|
||||
* transcript's own "second line" — see omp-transcript.ts), so identifying a
|
||||
* file never needs reading the whole thing (up to multi-MB, per that same
|
||||
* module's size cap). Bounded read only.
|
||||
*/
|
||||
const HEADER_READ_BYTES = 8 * 1024;
|
||||
|
||||
function readOmpSessionHeader(filePath: string): { id: string; cwd: string } | null {
|
||||
let raw: string;
|
||||
try {
|
||||
const fd = openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(HEADER_READ_BYTES);
|
||||
const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
|
||||
raw = buf.toString('utf-8', 0, bytesRead);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') {
|
||||
return { id: e.id, cwd: e.cwd };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide registry of OMP session ids already pinned to a live Codeman
|
||||
* session. Two omp tabs in the same case dir (`w1-foo`, `w2-foo`) resolve
|
||||
* against the SAME directory on disk — without this, both could pick the
|
||||
* newest file and alias onto each other's conversation (found in upstream PR
|
||||
* review, Ark0N/Codeman#353). Never released: this holds at most a handful of
|
||||
* short ids per real omp conversation ever pinned in this process's lifetime,
|
||||
* immaterial memory even after weeks of uptime — correctness here matters
|
||||
* more than reclaiming it.
|
||||
*/
|
||||
const claimedOmpSessionIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Safe variant of {@link findLatestOmpSessionId} for callers where two omp
|
||||
* sessions CAN share the same case directory — a dead-pane respawn, a
|
||||
* boot-recovery reattach, or a first-idle capture — instead of the narrower
|
||||
* cases where "newest file" is unambiguous by construction. Verifies each
|
||||
* candidate's own header `cwd` against `workingDir` (mangling is a lossy
|
||||
* one-way transform — see {@link mangleOmpWorkingDir} — so trusting the
|
||||
* filename-derived id alone isn't enough) and skips any id a sibling session
|
||||
* has already claimed. Claims the id it returns so a concurrent caller
|
||||
* resolving the same directory in the same tick can't double-claim it.
|
||||
*/
|
||||
export function resolveAndClaimOmpSessionId(workingDir: string): string | null {
|
||||
const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir));
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newestMtime = -Infinity;
|
||||
let newestId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
if (!OMP_SESSION_FILE_PATTERN.test(entry)) continue;
|
||||
const filePath = join(dir, entry);
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (mtimeMs <= newestMtime) continue;
|
||||
const header = readOmpSessionHeader(filePath);
|
||||
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
|
||||
newestMtime = mtimeMs;
|
||||
newestId = header.id;
|
||||
}
|
||||
if (newestId) claimedOmpSessionIds.add(newestId);
|
||||
return newestId;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The status-telemetry route writes the latest broadcast value here; the SSE
|
||||
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
|
||||
* chip shows immediately on a fresh page load / SSE reconnect — before any new
|
||||
* statusline render arrives, and without relying on per-browser localStorage.
|
||||
* The Claude status-telemetry route and host Codex poll merge their latest
|
||||
* values here. The SSE init snapshot (`getLightState`) replays the combined
|
||||
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
|
||||
* page load / SSE reconnect — before either source emits another sample, and
|
||||
* without relying on per-browser localStorage.
|
||||
*
|
||||
* Null until the first telemetry of the process; cleared naturally on restart.
|
||||
*
|
||||
@@ -13,8 +14,15 @@
|
||||
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): void {
|
||||
latest = value;
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const codex = latest?.codex;
|
||||
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setLatestCodexPlanUsage(value: object | null): Record<string, unknown> {
|
||||
latest = { ...(latest ?? {}), codex: value };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
|
||||
@@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
export type { CronPort } from './cron-port.js';
|
||||
export type { TabLayoutPort } from './tab-layout-port.js';
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Session } from '../../session.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
addSession(session: Session): void;
|
||||
addSession(session: Session): Promise<void>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @fileoverview Owner-scoped tab-layout capabilities exposed to route modules. */
|
||||
import type { TabLayoutService } from '../../tab-layout-service.js';
|
||||
|
||||
export type { LegacyOrderActor, LegacyOrderPutResult, SessionOrderProjectionChange } from '../../tab-layout-service.js';
|
||||
|
||||
export interface TabLayoutPort {
|
||||
readonly tabLayouts: TabLayoutService;
|
||||
}
|
||||
+414
-120
@@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
|
||||
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
|
||||
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
|
||||
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
|
||||
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
|
||||
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
|
||||
|
||||
@@ -536,10 +537,10 @@ class CodemanApp {
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
// Sessions whose full tmux scrollback has already been replayed this page load
|
||||
// (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the
|
||||
// flag was consumed by whichever session auto-selected at page load, so every
|
||||
// OTHER tab started life with one visible frame of history (issue #205).
|
||||
// Non-shell sessions whose full tmux scrollback has already been replayed this
|
||||
// page load (COD-47). Shells deliberately start from a bounded tail because
|
||||
// their scrollback can be very large; full history stays available on demand.
|
||||
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
|
||||
this._fullHistoryLoaded = new Set();
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
@@ -678,12 +679,19 @@ class CodemanApp {
|
||||
// Terminal write batching with DEC 2026 sync support
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
// xterm.write() parses asynchronously. Keep at most one live-output chunk
|
||||
// inside xterm so its private WriteBuffer cannot bypass our 128KB cap.
|
||||
this._terminalWriteInFlight = false;
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
|
||||
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
||||
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
|
||||
this._loadBufferQueue = null; // queued SSE events during buffer load
|
||||
this._bufferLoadSeq = 0;
|
||||
this._bufferLoadOwner = null;
|
||||
// Single-flight token for terminal buffer recovery. The identity check also
|
||||
// lets a session switch invalidate an older fetch without blocking the new tab.
|
||||
this._terminalRefreshOwner = null;
|
||||
|
||||
// Flicker filter state (buffers output after screen clears)
|
||||
this.flickerFilterBuffer = '';
|
||||
@@ -919,6 +927,8 @@ class CodemanApp {
|
||||
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyTabOrientation();
|
||||
this.initTabRailResize?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
this._installLineageStripScrollListener?.();
|
||||
@@ -986,6 +996,11 @@ class CodemanApp {
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applySessionListLayout();
|
||||
// A fresh device seeding tabOrientation from the server would otherwise
|
||||
// show no rail until a resize or a settings save: the boot-time call ran
|
||||
// before this async load resolved. Must stay AFTER applySessionListLayout
|
||||
// (same ordering rule as the settings-save path).
|
||||
this.applyTabOrientation?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
@@ -1598,7 +1613,15 @@ class CodemanApp {
|
||||
this._sseHandlerWrappers = new Map();
|
||||
for (const [event, method] of _SSE_HANDLER_MAP) {
|
||||
const fn = this[method];
|
||||
const wsOwnsTerminal =
|
||||
method === '_onSSETerminal' ||
|
||||
method === '_onSSENeedsRefresh' ||
|
||||
method === '_onSSEClearTerminal';
|
||||
this._sseHandlerWrappers.set(event, (e) => {
|
||||
// While WS owns terminal I/O, the parallel SSE stream is redundant.
|
||||
// Drop it before JSON.parse so a busy terminal cannot turn duplicate
|
||||
// SSE traffic/backpressure into another expensive buffer replay.
|
||||
if (wsOwnsTerminal && this._wsReady) return;
|
||||
try {
|
||||
fn.call(this, e.data ? JSON.parse(e.data) : {});
|
||||
} catch (err) {
|
||||
@@ -1845,18 +1868,18 @@ class CodemanApp {
|
||||
if (this.sessions.size === 0) this.stopSystemStatsPolling();
|
||||
}
|
||||
|
||||
// SSE wrappers — skip terminal events when WebSocket is delivering for this session.
|
||||
// SSE wrappers — skip terminal events while WebSocket owns active terminal I/O.
|
||||
// WS handler calls the underlying _onSession* methods directly.
|
||||
_onSSETerminal(data) {
|
||||
if (this._wsReady && this._wsSessionId === data.id) return;
|
||||
if (this._wsReady) return;
|
||||
this._onSessionTerminal(data);
|
||||
}
|
||||
_onSSENeedsRefresh(data) {
|
||||
if (this._wsReady && this._wsSessionId === data?.id) return;
|
||||
if (this._wsReady) return;
|
||||
this._onSessionNeedsRefresh(data);
|
||||
}
|
||||
_onSSEClearTerminal(data) {
|
||||
if (this._wsReady && this._wsSessionId === data?.id) return;
|
||||
if (this._wsReady) return;
|
||||
this._onSessionClearTerminal(data);
|
||||
}
|
||||
|
||||
@@ -1864,15 +1887,15 @@ class CodemanApp {
|
||||
if (data.id === this.activeSessionId) {
|
||||
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
||||
|
||||
// Hard cap: track total bytes queued in render buffers (pendingWrites +
|
||||
// flickerFilterBuffer). When rAF is throttled (tab
|
||||
// backgrounded, GPU busy), data accumulates with no flush, reaching
|
||||
// 889KB+ and freezing Chrome for minutes. Drop data beyond 128KB and
|
||||
// schedule a buffer reload to recover the display once the burst subsides.
|
||||
// Hard cap all app-owned render queues plus the one xterm chunk currently
|
||||
// parsing. Check the incoming frame too; otherwise a single large frame can
|
||||
// jump over the cap. Dropped data is recovered from the canonical buffer.
|
||||
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this.flickerFilterBuffer?.length || 0);
|
||||
if (queued > 131072) { // 128KB — drop to prevent accumulation
|
||||
// Schedule a self-recovery: reload the full terminal buffer once the
|
||||
+ (this.flickerFilterBuffer?.length || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this._terminalWriteInFlightBytes || 0);
|
||||
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
|
||||
// Schedule a self-recovery once the
|
||||
// queue drains (debounced to avoid hammering the API during sustained bursts).
|
||||
if (!this._clientDropRecoveryTimer) {
|
||||
this._clientDropRecoveryTimer = setTimeout(() => {
|
||||
@@ -2243,9 +2266,15 @@ class CodemanApp {
|
||||
? 'Antigravity'
|
||||
: mode === 'pi'
|
||||
? 'Pi'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
: mode === 'grok'
|
||||
? 'Grok'
|
||||
: mode === 'deepseek'
|
||||
? 'DeepSeek'
|
||||
: mode === 'omp'
|
||||
? 'OMP'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
@@ -2345,33 +2374,38 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
async _onSessionNeedsRefresh() {
|
||||
async _onSessionNeedsRefresh(event = {}) {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
if (!this.activeSessionId || !this.terminal) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
if (event?.id && event.id !== sessionId) return;
|
||||
if (!sessionId || !this.terminal) return;
|
||||
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||
if (this._isLoadingBuffer) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
if (this._terminalRefreshOwner?.sessionId === sessionId) return;
|
||||
const refreshOwner = { sessionId };
|
||||
this._terminalRefreshOwner = refreshOwner;
|
||||
try {
|
||||
// Recovery should restore the WHOLE picture, so ask for full history
|
||||
// rather than a tail. Measured on a 900-line shell pane: the tail rewrite
|
||||
// replaced an 869-row buffer with 158 rows, so every backpressure refresh
|
||||
// silently destroyed most of the scrollback it was meant to repair.
|
||||
//
|
||||
// A repaint-mode pane is the opposite case (tmux keeps ~one frame for it),
|
||||
// so the full capture can be SMALLER than what xterm already holds. Reuse
|
||||
// the same downgrade guard as the scroll-to-top re-pull and fall back to
|
||||
// the historical tail there, leaving that case exactly as it was.
|
||||
let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic
|
||||
// recovery stays bounded just like normal shell selection; only the
|
||||
// explicit "Load full history" action is allowed to pay for a full replay.
|
||||
// TUI modes still recover the whole picture, with the downgrade guard for
|
||||
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
|
||||
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
|
||||
let res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
let data = (await res.json())?.data ?? {};
|
||||
if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
data = (await res.json())?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
// history into the terminal the user is now looking at. The window is two
|
||||
// fetches wide in the fallback case, so this guard is not optional.
|
||||
if (this.activeSessionId !== sessionId) return;
|
||||
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return;
|
||||
if (data.terminalBuffer) {
|
||||
// This refresh is SERVER-triggered, so a user quietly reading scrollback
|
||||
// did not ask for it and must not be dragged to the bottom by it (#259).
|
||||
@@ -2401,6 +2435,8 @@ class CodemanApp {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('needsRefresh reload failed:', err);
|
||||
} finally {
|
||||
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2563,8 +2599,8 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
|
||||
// sample from any session drives the shared header chip.
|
||||
// Claude + Codex plan usage limits — account-global, so the latest sample
|
||||
// drives the shared header chip.
|
||||
_onSessionStatusTelemetry(data) {
|
||||
this.updatePlanUsageChip(data);
|
||||
// Persist last-known so the chip shows immediately on the next page load /
|
||||
@@ -2591,9 +2627,6 @@ class CodemanApp {
|
||||
const chip = document.getElementById('planUsageChip');
|
||||
if (!chip || !data) return;
|
||||
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
|
||||
const five = pct(data.fiveHour);
|
||||
const seven = pct(data.sevenDay);
|
||||
if (five === null && seven === null) return;
|
||||
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
|
||||
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
|
||||
// innerHTML here is XSS-safe ONLY because every interpolated value is a
|
||||
@@ -2607,12 +2640,28 @@ class CodemanApp {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
};
|
||||
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
|
||||
// The provider label only earns its space when there is more than one
|
||||
// provider to tell apart: a machine with Claude alone shows bare windows.
|
||||
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
|
||||
const labelled = hasWindows(data) && hasWindows(data.codex);
|
||||
const row = (provider, usage) => {
|
||||
const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean);
|
||||
if (!windows.length) return '';
|
||||
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
|
||||
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
|
||||
};
|
||||
const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
|
||||
chip.innerHTML = rows.length ? rows.join('') : '—';
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
chip.title =
|
||||
`Claude plan usage\n` +
|
||||
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
|
||||
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
|
||||
const details = (provider, usage) => {
|
||||
const lines = [];
|
||||
const five = pct(usage?.fiveHour);
|
||||
const seven = pct(usage?.sevenDay);
|
||||
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
|
||||
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
|
||||
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
|
||||
};
|
||||
chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
|
||||
}
|
||||
|
||||
// Scheduled runs
|
||||
@@ -3454,11 +3503,21 @@ class CodemanApp {
|
||||
this.flickerFilterActive = false;
|
||||
// Clear pending terminal writes
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this._clearTimer('_clientDropRecoveryTimer');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
// Release the one-chunk-in-flight gate with the rest of the write queue.
|
||||
// flushPendingWrites() early-returns while this is set, so a reset that
|
||||
// cleared everything EXCEPT this flag would leave live output permanently
|
||||
// stalled if xterm's parse callback never lands (disposed terminal, or a
|
||||
// throw inside the async parse). A late callback is harmless: it clears an
|
||||
// already-clear flag and schedules a flush.
|
||||
this._terminalWriteInFlight = false;
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._bufferLoadOwner = null;
|
||||
this._terminalRefreshOwner = null;
|
||||
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// Preserve local echo overlay text across SSE reconnect — just hide until
|
||||
@@ -3756,6 +3815,21 @@ class CodemanApp {
|
||||
return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header';
|
||||
}
|
||||
|
||||
resolveSessionSidebarFontSize(value) {
|
||||
const size = Number(value);
|
||||
// Default 12, matching the sidebar's historical 0.75rem name size: a user
|
||||
// who never touches the slider must not get silently restyled (14 here
|
||||
// bumped every existing sidebar install on the rail feature's release).
|
||||
return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 12;
|
||||
}
|
||||
|
||||
applySessionSidebarFontSize(settings = null) {
|
||||
const resolvedSettings = settings ?? this.loadAppSettingsFromStorage();
|
||||
const size = this.resolveSessionSidebarFontSize(resolvedSettings?.sessionSidebarFontSize);
|
||||
document.documentElement.style.setProperty('--session-sidebar-name-font-size', `${size}px`);
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the APPLIED layout off <html>, not the settings blob: this is called
|
||||
* per dragover event and per tab in render loops, and getSessionListLayout()
|
||||
@@ -3767,6 +3841,26 @@ class CodemanApp {
|
||||
return document.documentElement.dataset.sessionList === 'sidebar';
|
||||
}
|
||||
|
||||
_tabOrientation() {
|
||||
return document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the session list renders as a vertical column: the sidebar layout
|
||||
* OR the vertical tab rail. Axis decisions (drag insertion side, active-tab
|
||||
* scroll-into-view, floating-window anchors) must use THIS, not
|
||||
* isSessionSidebarActive() alone — the rail leaves data-session-list at
|
||||
* 'header', so the sidebar predicate reads a vertical rail as horizontal.
|
||||
*/
|
||||
_isVerticalTabList() {
|
||||
return this.isSessionSidebarActive() || this._tabOrientation() === 'vertical';
|
||||
}
|
||||
|
||||
shouldInlineSessionActions() {
|
||||
if (this.isSessionSidebarActive()) return !this.isSessionSidebarCollapsed();
|
||||
return this._tabOrientation() === 'vertical' && !document.documentElement.classList.contains('tab-rail-compact');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the sidebar is showing the DETAILED rows: the home screen's
|
||||
* per-session line ("created 3d ago · working 12m") plus a status pill.
|
||||
@@ -3782,6 +3876,38 @@ class CodemanApp {
|
||||
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the VERTICAL TAB RAIL (tabOrientation 'vertical') is showing the
|
||||
* detailed rows: the same "created 3d ago · working 12m" line and status pill
|
||||
* the rich sidebar and both home screens carry.
|
||||
*
|
||||
* A docked column is not a tab strip — that was the argument for the rich
|
||||
* sidebar, and the rail is a docked column too, so it defaults to rich and
|
||||
* `tabRailDetail: 'simple'` is the opt-out.
|
||||
*
|
||||
* The compact carve-out is not cosmetic: below 240px the rail already drops
|
||||
* the row actions to a hover affordance, and three lines of stamps in a
|
||||
* ~208px column ellipsize into noise. `_setTabRailWidth()` re-renders the
|
||||
* tabs whenever that class flips, so this gate is re-read at the right moment.
|
||||
*/
|
||||
isTabRailRich() {
|
||||
const root = document.documentElement;
|
||||
return (
|
||||
root.getAttribute('data-tab-orientation') === 'vertical' &&
|
||||
root.dataset.tabRailDetail === 'rich' &&
|
||||
!root.classList.contains('tab-rail-compact')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one gate the render paths ask: does THIS list draw detailed rows?
|
||||
* Either vertical surface can, and neither can be on at once (the sidebar
|
||||
* owns the tabs whenever it is active, which forces the rail off).
|
||||
*/
|
||||
isRichTabRows() {
|
||||
return this.isSessionSidebarRich() || this.isTabRailRich();
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -3859,17 +3985,22 @@ class CodemanApp {
|
||||
*/
|
||||
applySessionListLayout() {
|
||||
const mode = this.getSessionListLayout();
|
||||
this.applySessionSidebarFontSize();
|
||||
// 'sidebar' and 'sidebar-rich' are the same column; only row detail differs.
|
||||
const sidebar = mode === 'sidebar' || mode === 'sidebar-rich';
|
||||
const collapsed = this.isSessionSidebarCollapsed();
|
||||
const prevMode = document.documentElement.dataset.sessionList;
|
||||
const prevDetail = document.documentElement.dataset.sidebarDetail;
|
||||
const prevCollapsed = document.documentElement.dataset.sidebar;
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
const sidebarList = document.getElementById('sessionSidebarList');
|
||||
if (!tabsEl || !headerHost || !sidebarList) return;
|
||||
|
||||
const host = sidebar ? sidebarList : headerHost;
|
||||
const rail = document.getElementById('tabRail');
|
||||
const railOwnsTabs =
|
||||
!sidebar && document.documentElement.getAttribute('data-tab-orientation') === 'vertical';
|
||||
const host = sidebar ? sidebarList : railOwnsTabs && rail ? rail : headerHost;
|
||||
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
|
||||
|
||||
document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header';
|
||||
@@ -3878,7 +4009,7 @@ class CodemanApp {
|
||||
// would let the sidebar CSS style a strip that has nothing to style.
|
||||
document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple';
|
||||
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
|
||||
tabsEl.setAttribute('aria-orientation', sidebar ? 'vertical' : 'horizontal');
|
||||
tabsEl.setAttribute('aria-orientation', host === headerHost ? 'horizontal' : 'vertical');
|
||||
|
||||
const btn = document.getElementById('sidebarToggleBtn');
|
||||
if (btn) {
|
||||
@@ -3931,7 +4062,8 @@ class CodemanApp {
|
||||
const layoutChanged =
|
||||
prevMode !== document.documentElement.dataset.sessionList ||
|
||||
prevDetail !== document.documentElement.dataset.sidebarDetail;
|
||||
if (layoutChanged && prevTall === this._tallTabsEnabled) {
|
||||
const collapseChanged = prevCollapsed !== document.documentElement.dataset.sidebar;
|
||||
if ((layoutChanged || collapseChanged) && prevTall === this._tallTabsEnabled) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
|
||||
@@ -3949,7 +4081,7 @@ class CodemanApp {
|
||||
this.showHomeSessions?.();
|
||||
}
|
||||
// Only the rich rows carry stamps that go stale with no event behind them.
|
||||
if (this.isSessionSidebarRich()) this._startSidebarRichClock();
|
||||
if (this.isRichTabRows()) this._startSidebarRichClock();
|
||||
else this._stopSidebarRichClock();
|
||||
}
|
||||
|
||||
@@ -4066,13 +4198,14 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row model for a rich sidebar row: which state the session is in,
|
||||
* when it was first created, and how long it has been in that state.
|
||||
* The per-row model for a rich row (detailed sidebar or vertical tab rail):
|
||||
* which state the session is in, when it was first created, and how long it
|
||||
* has been in that state.
|
||||
*
|
||||
* Classification is `_mobileOverviewState()` and the state duration is
|
||||
* `_mobileOverviewSince()` (both mobile-overview.js), NOT re-derived here —
|
||||
* the sidebar, the desktop home rail and the phone overview must never
|
||||
* disagree about what "working" means or about which stamp measures it.
|
||||
* the sidebar, the rail, the desktop home rail and the phone overview must
|
||||
* never disagree about what "working" means or about which stamp measures it.
|
||||
*
|
||||
* Guarded like every other cross-file consumer in this app: a stale cached
|
||||
* mobile-overview.js must degrade to a row with no meta line, not throw and
|
||||
@@ -4119,7 +4252,21 @@ class CodemanApp {
|
||||
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
|
||||
}
|
||||
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
|
||||
return `<span class="tab-meta" data-i18n-skip>${parts.join('')}</span>`;
|
||||
// Both absolute stamps ALSO on the line itself, not only on the two items.
|
||||
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
|
||||
// rule), and a tooltip on a `display: none` element has no hover target —
|
||||
// so without this the created stamp is not merely shrunk, it is gone with
|
||||
// no way to ask for it. The pill and the gaps around the stamps are the
|
||||
// hover targets that remain; an item's own title still wins over this one
|
||||
// where the item is visible.
|
||||
const lineTitle = [
|
||||
row.createdAt ? `First created: ${new Date(row.createdAt).toLocaleString()}` : '',
|
||||
row.since && row.since.at ? `${row.since.key}: ${new Date(row.since.at).toLocaleString()}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' \u00B7 ');
|
||||
const lineTitleAttr = lineTitle ? ` title="${escapeHtml(lineTitle)}"` : '';
|
||||
return `<span class="tab-meta"${lineTitleAttr} data-i18n-skip>${parts.join('')}</span>`;
|
||||
}
|
||||
|
||||
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
|
||||
@@ -4164,7 +4311,7 @@ class CodemanApp {
|
||||
_startSidebarRichClock() {
|
||||
if (this._sidebarRichClock) return;
|
||||
this._sidebarRichClock = setInterval(() => {
|
||||
if (!this.isSessionSidebarRich()) {
|
||||
if (!this.isRichTabRows()) {
|
||||
this._stopSidebarRichClock();
|
||||
return;
|
||||
}
|
||||
@@ -4235,12 +4382,13 @@ class CodemanApp {
|
||||
container.querySelector('.session-tab.active');
|
||||
if (!tab) return;
|
||||
|
||||
// Sidebar layout: the list scrolls VERTICALLY in its own scroller, so the
|
||||
// horizontal computeTabScrollLeft math below would always no-op (scrollLeft
|
||||
// pinned at 0). With 25+ sessions the active row is routinely below the
|
||||
// fold; 'nearest' never scrolls when it is already visible, and only the
|
||||
// list's own scroller moves — the drawer and document stay put.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
// Sidebar layout AND the vertical rail: the list scrolls VERTICALLY in its
|
||||
// own scroller, so the horizontal computeTabScrollLeft math below would
|
||||
// always no-op (scrollLeft pinned at 0). With 25+ sessions the active row
|
||||
// is routinely below the fold; 'nearest' never scrolls when it is already
|
||||
// visible, and only the list's own scroller moves — drawer/rail and
|
||||
// document stay put.
|
||||
if (this._isVerticalTabList()) {
|
||||
tab.scrollIntoView({ block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
@@ -4271,12 +4419,13 @@ class CodemanApp {
|
||||
|
||||
/**
|
||||
* Where a floating window (subagent / ultracode) attaches to its parent tab.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar: to the
|
||||
* RIGHT of the tab, connector runs horizontally — otherwise the window spawns
|
||||
* on top of the sidebar and its bezier loops backwards underneath it.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar AND the
|
||||
* vertical rail: to the RIGHT of the tab, connector runs horizontally —
|
||||
* otherwise the window spawns on top of the list and its bezier loops
|
||||
* backwards underneath it.
|
||||
*/
|
||||
_tabAnchor(rect) {
|
||||
if (this.isSessionSidebarActive()) {
|
||||
if (this._isVerticalTabList()) {
|
||||
return {
|
||||
x: rect.right,
|
||||
y: rect.top + rect.height / 2,
|
||||
@@ -4379,7 +4528,7 @@ class CodemanApp {
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
// the DOM and the loop below runs for every session on every SSE tick.
|
||||
const richRows = this.isSessionSidebarRich();
|
||||
const richRows = this.isRichTabRows();
|
||||
// Incremental update - only modify changed properties
|
||||
for (const [id, session] of this.sessions) {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
@@ -4474,9 +4623,17 @@ class CodemanApp {
|
||||
const nameEl = tab.querySelector('.tab-name');
|
||||
if (nameEl) {
|
||||
const _p = parseSessionPrefix(name);
|
||||
const _label = _p && _p.suffix ? _p.suffix : name;
|
||||
if (nameEl.textContent !== _label) {
|
||||
nameEl.textContent = _label;
|
||||
if (nameEl.dataset.fullName !== name) {
|
||||
nameEl.replaceChildren();
|
||||
if (_p && _p.suffix) {
|
||||
const prefix = document.createElement('span');
|
||||
prefix.className = 'tab-name-prefix';
|
||||
prefix.textContent = `${_p.prefix}: `;
|
||||
nameEl.append(prefix, document.createTextNode(_p.suffix));
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
nameEl.dataset.fullName = name;
|
||||
tab.title = _p && _p.suffix
|
||||
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
|
||||
: (session.workingDir || '');
|
||||
@@ -4527,9 +4684,11 @@ class CodemanApp {
|
||||
// Need to add badge - insert before the action-icon overlay so the
|
||||
// badge stays a direct child of the tab (outside .tab-actions)
|
||||
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
|
||||
const actionsEl = tab.querySelector('.tab-actions');
|
||||
const actionsEl = tab.querySelector(':scope > .tab-actions');
|
||||
if (actionsEl) {
|
||||
actionsEl.insertAdjacentHTML('beforebegin', badgeHtml);
|
||||
} else {
|
||||
tab.insertAdjacentHTML('beforeend', badgeHtml);
|
||||
}
|
||||
} else if (minimizedCount === 0 && subagentBadgeEl) {
|
||||
// Count went to 0 - remove badge
|
||||
@@ -4559,11 +4718,12 @@ class CodemanApp {
|
||||
// The full-render path already redraws the connection SVG; this incremental
|
||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: lineage arcs, or — in sidebar layout,
|
||||
// where lineage is skipped and the edge count stays 0 — the subagent/
|
||||
// ultracode connectors, whose rows a badge changes the HEIGHT of. Same
|
||||
// widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive()) this.updateConnectionLines();
|
||||
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
||||
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
||||
// rail, which can show connectors with zero lineage edges too) — the
|
||||
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
||||
// Same widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
}
|
||||
@@ -4587,6 +4747,17 @@ class CodemanApp {
|
||||
const defaults = this.getDefaultSettings();
|
||||
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
|
||||
|
||||
const orientation = window.CodemanTabOverflow?.resolveTabOrientation
|
||||
? window.CodemanTabOverflow.resolveTabOrientation({
|
||||
deviceType,
|
||||
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
|
||||
})
|
||||
: 'horizontal';
|
||||
if (orientation === 'vertical') {
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualTwoRows || deviceType !== 'desktop') {
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
return;
|
||||
@@ -4627,6 +4798,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
this.closeTabRailActionMenu?.();
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
@@ -4665,9 +4837,9 @@ class CodemanApp {
|
||||
// into view replaces it.
|
||||
const parts = [];
|
||||
const tabOrder = this.sessionOrder;
|
||||
// Read once, not per session: isSessionSidebarRich() touches the DOM and
|
||||
// Read once, not per session: isRichTabRows() touches the DOM and
|
||||
// this loop runs for every tab on every full rebuild.
|
||||
const richRows = this.isSessionSidebarRich();
|
||||
const richRows = this.isRichTabRows();
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
@@ -4704,14 +4876,17 @@ class CodemanApp {
|
||||
// JUST the description on the tab; the generated w<n>-<case> id moves to the
|
||||
// tooltip and stays visible in the session settings modal.
|
||||
const parsedName = parseSessionPrefix(name);
|
||||
const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name;
|
||||
const tabLabel = parsedName && parsedName.suffix
|
||||
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
|
||||
: escapeHtml(name);
|
||||
const tabTooltip = parsedName && parsedName.suffix
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
|
||||
// Rich sidebar rows only: the home screen's created/state stamps and a
|
||||
// status pill. richRow is null in every other layout, and both helpers
|
||||
// below collapse to '' — the header strip's markup is unchanged.
|
||||
// Rich rows only (the detailed sidebar OR the vertical tab rail): the home
|
||||
// screen's created/state stamps and a status pill. richRow is null in every
|
||||
// other layout, and both helpers below collapse to '' — the header strip's
|
||||
// markup is unchanged.
|
||||
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
|
||||
const richMeta = this._sidebarRichMetaHTML(richRow);
|
||||
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
|
||||
@@ -4719,14 +4894,18 @@ class CodemanApp {
|
||||
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
|
||||
: '';
|
||||
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span>
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
@@ -4735,7 +4914,7 @@ class CodemanApp {
|
||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||
${subagentBadge}
|
||||
${ultracodeBadge}
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span></span>
|
||||
${inlineSessionActions ? '' : tabActionsHtml}
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
}
|
||||
@@ -4958,9 +5137,9 @@ class CodemanApp {
|
||||
// inside the handler — these listeners survive a layout flip between
|
||||
// renders, so capturing the axis at bind time would go stale.
|
||||
// drag-over-left/-right keep their names and now read as before/after;
|
||||
// the sidebar CSS just draws them as top/bottom edges.
|
||||
// the sidebar/rail CSS just draws them as top/bottom edges.
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
const insertBefore = this._isVerticalTabList()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
@@ -4984,7 +5163,7 @@ class CodemanApp {
|
||||
|
||||
// Determine insertion position (same axis rule as the dragover handler)
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
const insertBefore = this._isVerticalTabList()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
@@ -5202,11 +5381,20 @@ class CodemanApp {
|
||||
this._tabCompletionBaseText = null;
|
||||
this._clearTimer('_tabCompletionFallback');
|
||||
this._clearTimer('_clientDropRecoveryTimer');
|
||||
this._terminalRefreshOwner = null;
|
||||
|
||||
// Clean up pending terminal writes to prevent old session data from appearing in new session
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
// Release the one-chunk-in-flight gate with the rest of the write queue.
|
||||
// flushPendingWrites() early-returns while this is set, so a reset that
|
||||
// cleared everything EXCEPT this flag would leave live output permanently
|
||||
// stalled if xterm's parse callback never lands (disposed terminal, or a
|
||||
// throw inside the async parse). A late callback is harmless: it clears an
|
||||
// already-clear flag and schedules a flush.
|
||||
this._terminalWriteInFlight = false;
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._bufferLoadOwner = null;
|
||||
@@ -5268,6 +5456,22 @@ class CodemanApp {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
|
||||
_recordTerminalLoadTiming(timing) {
|
||||
this._lastTerminalLoadTiming = timing;
|
||||
console.info('[TERMINAL-PERF]', timing);
|
||||
const resetAndParseMs =
|
||||
(timing.cacheResetAndParseMs || 0) +
|
||||
(timing.freshResetAndParseMs || 0) +
|
||||
(timing.resetAndParseMs || 0);
|
||||
const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0;
|
||||
_crashDiag.log(
|
||||
`TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` +
|
||||
`ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` +
|
||||
`reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
|
||||
`server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
|
||||
* while already at the top of what the browser has.
|
||||
@@ -5296,6 +5500,11 @@ class CodemanApp {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
if (this.detachedSessions?.has(sessionId)) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
// A shell's full capture can be many megabytes. Replaying it from an
|
||||
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||
// behind the explicit "Load full history" button.
|
||||
if (!force && session?.mode === 'shell') return;
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
@@ -5307,13 +5516,32 @@ class CodemanApp {
|
||||
this._fullHistoryRepullAt.set(sessionId, now);
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
trigger: force ? 'full-history-button' : 'full-history-scroll',
|
||||
mode: session?.mode || 'unknown',
|
||||
full: true,
|
||||
source: payload.source || 'unknown',
|
||||
chars: buffer?.length || 0,
|
||||
ttfbMs: headersReceivedAt - requestStartedAt,
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
resetAndParseMs: 0,
|
||||
totalMs: 0,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
refused: false,
|
||||
};
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
timing.refused = true;
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-refused-downgrade');
|
||||
// The browser already holds more than tmux can give back, so there is
|
||||
@@ -5324,17 +5552,32 @@ class CodemanApp {
|
||||
this._setHistoryTruncation(sessionId, payload);
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
const replayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
if (this.activeSessionId !== sessionId) return;
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
const {
|
||||
parsedAt,
|
||||
bufferLength: parsedBufferLength,
|
||||
completed,
|
||||
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||
if (!completed || this.activeSessionId !== sessionId) return;
|
||||
// Keep shell tab restores bounded too. A user-triggered full-history pull
|
||||
// may be tens of MB; caching it would replay that whole payload again on
|
||||
// the next tab switch before the normal 1MB tail fetch replaces it.
|
||||
if (this.sessions.get(sessionId)?.mode !== 'shell') {
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
} else {
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
}
|
||||
// Hold the user's place. The replay is a superset that grew the buffer
|
||||
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
|
||||
// rows down; scrolling there reveals the recovered history above it instead
|
||||
// of teleporting them to the bottom the way a normal buffer load does.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
const delta = parsedBufferLength - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
} catch {
|
||||
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
|
||||
} finally {
|
||||
@@ -5465,8 +5708,11 @@ class CodemanApp {
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
this._terminalWriteInFlight = false;
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._terminalRefreshOwner = null;
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
@@ -5497,6 +5743,7 @@ class CodemanApp {
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
this._activateFileBrowserSession?.(sessionId);
|
||||
// Repaint the partial-history banner for the tab being switched TO. The
|
||||
// replay paths refresh it when their fetch lands; without this the previous
|
||||
// session's notice stays on screen until then (#258).
|
||||
@@ -5614,6 +5861,7 @@ class CodemanApp {
|
||||
// COD-144: track whether the load painted nothing (empty fetch + no cache).
|
||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||
let bufferWasEmpty = false;
|
||||
let cacheResetAndParseMs = 0;
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
@@ -5691,23 +5939,30 @@ class CodemanApp {
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
let clearedForBusy = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
|
||||
let clearedBeforeFresh = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
const cacheReplayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite(
|
||||
cachedBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
);
|
||||
cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
} else if (sessionIsBusy || session?.mode === 'shell') {
|
||||
// Busy sessions have stale caches. Shell sessions deliberately skip even
|
||||
// an idle cache so a changed 1MB tail cannot cause two back-to-back parses.
|
||||
this._resetTerminalForReplay();
|
||||
clearedForBusy = true;
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
clearedBeforeFresh = true;
|
||||
_crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
// Give TUI sessions a short chance to redraw after resize before the
|
||||
@@ -5725,26 +5980,29 @@ class CodemanApp {
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
// The first load OF EACH SESSION this page load requests the full tmux
|
||||
// scrollback (?full=1, COD-47) so history that scrolled off the server's byte
|
||||
// buffer comes back. Later switches to an already-replayed session keep the
|
||||
// fast ?tail= frame path, which is why this is a Set and not a flag: the flag
|
||||
// version gave the full replay to the auto-selected tab and one frame of
|
||||
// history to every other one (issue #205).
|
||||
const useFullHistory = !this._fullHistoryLoaded.has(sessionId);
|
||||
// TUI sessions still get one canonical full replay per page (COD-47/#205).
|
||||
// A shell can retain hundreds of thousands of plain scrollback lines, so
|
||||
// automatically replaying all of them makes tab selection scale with the
|
||||
// entire session. Load its bounded 1MB tail first; the existing truncation
|
||||
// banner action fetches ?full=1 when the user explicitly asks for it.
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
const headersReceivedAt = performance.now();
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
let freshResetAndParseMs = 0;
|
||||
if (data.terminalBuffer) {
|
||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||
@@ -5753,10 +6011,11 @@ class CodemanApp {
|
||||
// something other than the cache, so the fetched buffer must be
|
||||
// replayed even when it byte-matches the cache.
|
||||
const needsRewrite =
|
||||
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
|
||||
restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
|
||||
if (needsRewrite) {
|
||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
const replayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
// Truncation is reported OUT OF BAND (#258). This used to write a grey
|
||||
// "... earlier output truncated ..." line into the
|
||||
@@ -5764,7 +6023,12 @@ class CodemanApp {
|
||||
// cannot be actioned, and is indistinguishable from real CLI output.
|
||||
this._setHistoryTruncation(sessionId, data);
|
||||
// Use chunked write for large buffers to avoid UI jank
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
);
|
||||
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
@@ -5773,22 +6037,42 @@ class CodemanApp {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
// Update cache (cap at 20 entries)
|
||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||
if (this.terminalBufferCache.size > 20) {
|
||||
// Evict oldest entry (first key in Map iteration order)
|
||||
const oldest = this.terminalBufferCache.keys().next().value;
|
||||
this.terminalBufferCache.delete(oldest);
|
||||
// Shell selection always uses a fresh bounded tail, so retaining its
|
||||
// payload only wastes memory and can evict useful TUI caches.
|
||||
if (session?.mode === 'shell') {
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
} else {
|
||||
// Update cache (cap at 20 entries)
|
||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||
if (this.terminalBufferCache.size > 20) {
|
||||
// Evict oldest entry (first key in Map iteration order)
|
||||
const oldest = this.terminalBufferCache.keys().next().value;
|
||||
this.terminalBufferCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
} else if (!cachedBuffer) {
|
||||
// No fresh buffer and no cache — clear any stale content
|
||||
this._resetTerminalForReplay();
|
||||
} else if (!cachedBuffer || clearedBeforeFresh) {
|
||||
// Nothing was painted. If this path was not already cleared above,
|
||||
// clear stale content now; either way queued live output must be flushed.
|
||||
if (!clearedBeforeFresh) this._resetTerminalForReplay();
|
||||
bufferWasEmpty = true;
|
||||
}
|
||||
|
||||
const terminalLoadTiming = {
|
||||
trigger: 'session-select',
|
||||
mode: session?.mode || 'unknown',
|
||||
full: useFullHistory,
|
||||
source: data.source || 'unknown',
|
||||
chars: data.terminalBuffer?.length || 0,
|
||||
ttfbMs: headersReceivedAt - fetchStartedAt,
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
cacheResetAndParseMs,
|
||||
freshResetAndParseMs,
|
||||
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
};
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad internally (discarding queued events to prevent duplicate
|
||||
// content); if we skipped the write (cache hit or empty), call it here.
|
||||
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||
// the write (cache hit or empty), call it here.
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
@@ -5917,9 +6201,12 @@ class CodemanApp {
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
}
|
||||
const selectDoneMs = performance.now() - _selStart;
|
||||
terminalLoadTiming.selectDoneMs = selectDoneMs;
|
||||
this._recordTerminalLoadTiming(terminalLoadTiming);
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
_crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||
this._restoringFlushedState = false;
|
||||
@@ -5930,6 +6217,7 @@ class CodemanApp {
|
||||
|
||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||
_cleanupSessionData(sessionId) {
|
||||
this.closeTabRailActionMenu?.();
|
||||
// If the deleted session is currently being renamed, abort the rename
|
||||
// so the inline <input> doesn't ghost as a stale tab on screen.
|
||||
if (this._activeRename?.sessionId === sessionId) {
|
||||
@@ -6059,7 +6347,13 @@ class CodemanApp {
|
||||
? 'Kill Tmux & Antigravity'
|
||||
: session.mode === 'pi'
|
||||
? 'Kill Tmux & Pi'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'grok'
|
||||
? 'Kill Tmux & Grok'
|
||||
: session.mode === 'deepseek'
|
||||
? 'Kill Tmux & DeepSeek'
|
||||
: session.mode === 'omp'
|
||||
? 'Kill Tmux & OMP'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts)
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts)
|
||||
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
|
||||
*
|
||||
* @dependency None (first in load order)
|
||||
@@ -156,6 +156,48 @@ function shouldAutoWrapTabs(input) {
|
||||
return scrollWidth > clientWidth + 1;
|
||||
}
|
||||
|
||||
function resolveTabOrientation(input) {
|
||||
if (!input || input.setting !== 'vertical') return 'horizontal';
|
||||
if (input.deviceType === 'mobile') return 'horizontal';
|
||||
return 'vertical';
|
||||
}
|
||||
|
||||
const TAB_RAIL_MIN_WIDTH = 208;
|
||||
const TAB_RAIL_DEFAULT_WIDTH = 256;
|
||||
/** Detailed rows carry a third line, and it ellipsizes at 256px — see the
|
||||
rich sidebar's own 300px column. 320px is the existing Wide preset. */
|
||||
const TAB_RAIL_RICH_DEFAULT_WIDTH = 320;
|
||||
const TAB_RAIL_MAX_WIDTH = 360;
|
||||
|
||||
function resolveTabRailWidth(input = {}) {
|
||||
const viewportWidth = Number(input.viewportWidth);
|
||||
const mainWidth = Number(input.mainWidth);
|
||||
const minTerminalWidth = Number(input.minTerminalWidth);
|
||||
const limits = [TAB_RAIL_MAX_WIDTH];
|
||||
if (Number.isFinite(viewportWidth) && viewportWidth > 0) limits.push(Math.floor(viewportWidth * 0.4));
|
||||
if (Number.isFinite(mainWidth) && mainWidth > 0 && Number.isFinite(minTerminalWidth) && minTerminalWidth > 0) {
|
||||
limits.push(Math.floor(mainWidth - minTerminalWidth));
|
||||
}
|
||||
const effectiveMax = Math.max(TAB_RAIL_MIN_WIDTH, Math.min(...limits));
|
||||
const requested = Number(input.width);
|
||||
const width = Number.isFinite(requested) ? requested : TAB_RAIL_DEFAULT_WIDTH;
|
||||
return Math.round(Math.min(effectiveMax, Math.max(TAB_RAIL_MIN_WIDTH, width)));
|
||||
}
|
||||
|
||||
function resolveTabRailKeyboardWidth(input = {}) {
|
||||
let width;
|
||||
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
|
||||
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
|
||||
// Enter resets to the caller's effective default (the rich rail's is the
|
||||
// Wide preset, not 256 — see _defaultTabRailWidth); absent, the base default.
|
||||
else if (input.key === 'Enter') width = Number(input.defaultWidth) || TAB_RAIL_DEFAULT_WIDTH;
|
||||
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
|
||||
const direction = input.key === 'ArrowLeft' ? -1 : 1;
|
||||
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
|
||||
} else return null;
|
||||
return resolveTabRailWidth({ ...input, width });
|
||||
}
|
||||
|
||||
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
|
||||
// view. Landing a tab flush against the edge reads as "this is the last one";
|
||||
// the gap is what tells the user there is more strip to swipe to.
|
||||
@@ -243,6 +285,9 @@ const LINEAGE_DIP_MAX_PX = 64;
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
|
||||
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||
// (session-lineage.js). Every arc leaving one tab shares its colour however many
|
||||
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
|
||||
@@ -265,21 +310,44 @@ function computeLineagePath(input) {
|
||||
const ch = Number(child.height) || 0;
|
||||
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
|
||||
|
||||
const px = Number(parent.left) + pw / 2;
|
||||
const cx = Number(child.left) + cw / 2;
|
||||
if (!Number.isFinite(px) || !Number.isFinite(cx)) return null;
|
||||
|
||||
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
||||
const strip = input?.strip;
|
||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||
const pLeft = Number(parent.left);
|
||||
const cLeft = Number(child.left);
|
||||
const pTop = Number(parent.top);
|
||||
const cTop = Number(child.top);
|
||||
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
const py = pTop + ph / 2;
|
||||
const cy = cTop + ch / 2;
|
||||
if (strip && Number(strip.height) > 0) {
|
||||
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (py < min || py > max || cy < min || cy > max) return null;
|
||||
}
|
||||
|
||||
const stripLeft =
|
||||
strip && Number.isFinite(Number(strip.left))
|
||||
? Number(strip.left)
|
||||
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||||
const requestedTrack =
|
||||
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
|
||||
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
|
||||
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
|
||||
return { d, endX: cLeft, endY: cy, sameRow: false };
|
||||
}
|
||||
|
||||
const px = pLeft + pw / 2;
|
||||
const cx = cLeft + cw / 2;
|
||||
if (strip && Number(strip.width) > 0) {
|
||||
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (px < min || px > max || cx < min || cx > max) return null;
|
||||
}
|
||||
|
||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||
const pTop = Number(parent.top);
|
||||
const pBottom = pTop + ph;
|
||||
const cTop = Number(child.top);
|
||||
const cBottom = cTop + ch;
|
||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||
|
||||
@@ -617,9 +685,18 @@ if (typeof window !== 'undefined') {
|
||||
window.shouldSkipWebGL = shouldSkipWebGL;
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
resolveTabOrientation,
|
||||
computeTabScrollLeft,
|
||||
TAB_SCROLL_REVEAL_PX,
|
||||
};
|
||||
window.CodemanTabRail = {
|
||||
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
|
||||
RICH_DEFAULT_WIDTH: TAB_RAIL_RICH_DEFAULT_WIDTH,
|
||||
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
|
||||
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
|
||||
resolveWidth: resolveTabRailWidth,
|
||||
resolveKeyboardWidth: resolveTabRailKeyboardWidth,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
@@ -628,6 +705,8 @@ if (typeof window !== 'undefined') {
|
||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
||||
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
|
||||
COLORS: LINEAGE_COLORS,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
@@ -876,6 +955,7 @@ const SSE_EVENTS = {
|
||||
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
|
||||
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
|
||||
HOOK_STOP: 'hook:stop',
|
||||
HOOK_AGENT_WORKING: 'hook:agent_working',
|
||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||
|
||||
@@ -969,6 +1049,7 @@ const SSE_EVENTS = {
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WEBVIEW_CHANGED: 'webview:changed',
|
||||
TAB_LAYOUT_CHANGED: 'tab:layoutChanged',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -78,6 +78,9 @@ const HOME_SESSIONS_MODE_BADGE = {
|
||||
gemini: 'gm',
|
||||
antigravity: 'ag',
|
||||
pi: 'pi',
|
||||
grok: 'gk',
|
||||
deepseek: 'ds',
|
||||
omp: 'om',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
@@ -87,12 +90,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Width-driven, like every other layout decision in the app. Explicitly yields
|
||||
* to the phone overview: that surface already lists the same sessions, and two
|
||||
* lists of the same thing on one screen is worse than none.
|
||||
* to the phone overview and persistent vertical tab rail: those surfaces already
|
||||
* list the same sessions, and two lists of the same thing on one screen is worse
|
||||
* than none.
|
||||
*/
|
||||
shouldShowHomeSessions() {
|
||||
if (this.isSoloWindow) return false;
|
||||
if (this.shouldUseMobileOverview?.()) return false;
|
||||
if (document.documentElement.getAttribute('data-tab-orientation') === 'vertical') return false;
|
||||
// The sidebar layout already docks the full session list flush left at full
|
||||
// height — the rail would render the same list right next to it (and z-wise
|
||||
// UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay).
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
'Run Gemini': '运行 Gemini',
|
||||
'Run Antigravity': '运行 Antigravity',
|
||||
'Run Pi': '运行 Pi',
|
||||
'Run Grok': '运行 Grok',
|
||||
'Run DeepSeek': '运行 DeepSeek',
|
||||
'Run Shell': '运行 Shell',
|
||||
'Select AI backend': '选择 AI 后端',
|
||||
'Create New Case': '新建案例',
|
||||
@@ -232,6 +234,8 @@
|
||||
'Redraw Terminal Button': '重绘终端按钮',
|
||||
'Tab Bar': '标签栏',
|
||||
'Session List Layout': '会话列表布局',
|
||||
'Session Name Font Size': '会话名称字体大小',
|
||||
'Adjust only session names in the vertical sidebar.': '仅调整垂直侧边栏中的会话名称。',
|
||||
'Header tab strip': '顶栏标签条',
|
||||
'Left sidebar': '左侧边栏',
|
||||
'Left sidebar simple': '左侧边栏(简洁)',
|
||||
|
||||
+106
-3
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -192,7 +192,7 @@
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -358,6 +358,20 @@
|
||||
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
|
||||
<div
|
||||
id="tabRailResizeHandle"
|
||||
class="tab-rail-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize session rail"
|
||||
aria-valuemin="208"
|
||||
aria-valuemax="360"
|
||||
aria-valuenow="256"
|
||||
tabindex="0"
|
||||
></div>
|
||||
</aside>
|
||||
|
||||
<!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in
|
||||
markup: applySessionListLayout() moves #sessionTabs in here, so the
|
||||
vertical list is the exact same DOM node as the header strip and every
|
||||
@@ -430,6 +444,18 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Pi
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-grok" id="welcomeGrokBtn" style="display: none;" onclick="app.setRunMode('grok'); app.runGrok()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Grok
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run DeepSeek
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OMP
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
@@ -509,6 +535,7 @@
|
||||
desktop (which never loads mobile.css) can never render it. -->
|
||||
<div class="mobile-overview" id="mobileOverview" hidden></div>
|
||||
</main>
|
||||
<div class="tab-rail-resize-shield" id="tabRailResizeShield" aria-hidden="true" hidden></div>
|
||||
|
||||
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
||||
<div class="project-insights-panel" id="projectInsightsPanel">
|
||||
@@ -548,6 +575,7 @@
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -607,6 +635,21 @@
|
||||
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
|
||||
<span class="run-mode-dot pi"></span>Pi
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
|
||||
<span class="run-mode-dot grok"></span>Grok
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek
|
||||
</button>
|
||||
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
|
||||
DeepSeek ships no terminal front door, so the fix is an install,
|
||||
not a greyed-out entry the user cannot act on. -->
|
||||
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
|
||||
<span class="run-mode-dot omp"></span>OMP
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
@@ -619,6 +662,14 @@
|
||||
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
||||
<span class="run-mode-dot web"></span>Add URL…
|
||||
</button>
|
||||
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
|
||||
surface (the terminal one is third-party), so it gets a shortcut:
|
||||
POST /api/deepseek/web starts a background `dsh web` fenced to
|
||||
this origin, and the URL opens as a managed web tab.
|
||||
Shown only when dsh is installed. -->
|
||||
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek web UI…
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
@@ -884,6 +935,9 @@
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="antigravity">Antigravity</option>
|
||||
<option value="pi">Pi</option>
|
||||
<option value="grok">Grok</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="omp">OMP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -1869,6 +1923,39 @@
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Orientation</span>
|
||||
<span class="set-row-desc">Keep tabs in the header or place them beside the terminal. Phones stay horizontal.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabOrientation" class="set-select">
|
||||
<option value="horizontal">Horizontal (top)</option>
|
||||
<option value="vertical">Vertical (side rail)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Rows</span>
|
||||
<span class="set-row-desc">Detailed rows carry the home screen's per-session line (created, how long it has been working or idle) and a status pill. Needs ~280px of rail; a rail narrower than 240px drops back to simple rows.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailDetail" class="set-select">
|
||||
<option value="rich">Detailed</option>
|
||||
<option value="simple">Simple (name only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Width</span>
|
||||
<span class="set-row-desc">Set the preferred rail width for this device.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailWidth" class="set-select">
|
||||
<option value="208">Compact (208px)</option>
|
||||
<option value="256">Default (256px)</option>
|
||||
<option value="320">Wide (320px)</option>
|
||||
<option value="360">Maximum (360px)</option>
|
||||
<option value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="session list layout sidebar tab strip vertical">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Session List Layout</span>
|
||||
@@ -1880,6 +1967,18 @@
|
||||
<option value="sidebar-rich">Left sidebar</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="session sidebar name font size text">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label" id="appSettingsSessionSidebarFontSizeLabel">Session Name Font Size</span>
|
||||
<span class="set-row-desc">Adjust only session names in the vertical sidebar.</span>
|
||||
</div>
|
||||
<label class="set-range-field" for="appSettingsSessionSidebarFontSize">
|
||||
<input type="range" id="appSettingsSessionSidebarFontSize" min="11" max="18" step="1" value="12"
|
||||
aria-labelledby="appSettingsSessionSidebarFontSizeLabel"
|
||||
oninput="document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent=this.value+' px'">
|
||||
<output id="appSettingsSessionSidebarFontSizeValue" for="appSettingsSessionSidebarFontSize">12 px</output>
|
||||
</label>
|
||||
</div>
|
||||
<div class="set-row" data-search="tall tabs folder name two rows">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tall Tabs</span>
|
||||
@@ -2616,6 +2715,9 @@
|
||||
<option value="opencode" data-cli="opencode">OpenCode</option>
|
||||
<option value="antigravity" data-cli="antigravity">Antigravity</option>
|
||||
<option value="pi" data-cli="pi">Pi</option>
|
||||
<option value="grok" data-cli="grok">Grok</option>
|
||||
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||
<option value="omp" data-cli="omp">OMP</option>
|
||||
<option value="shell">Shell (no agent)</option>
|
||||
</select>
|
||||
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
||||
@@ -2756,7 +2858,7 @@
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi + tmux.</span>
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
@@ -3323,6 +3425,7 @@
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
|
||||
+27
-25
@@ -27,8 +27,8 @@
|
||||
*
|
||||
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||
* Keyboard typing of single printable characters still goes through the
|
||||
* keydown handler (immediate, no debounce).
|
||||
* Physical-keyboard commits are flushed immediately after the input event
|
||||
* exposes the final browser/IME text; keydown never guesses that text.
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
@@ -56,8 +56,7 @@ const CjkInput = (() => {
|
||||
let _compositionFlushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
let _keydownSentText = '';
|
||||
let _printableKeydownAt = null;
|
||||
const _listeners = {};
|
||||
|
||||
const PHANTOM = '';
|
||||
@@ -197,6 +196,7 @@ const CjkInput = (() => {
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_printableKeydownAt = null;
|
||||
_flushTimer = null;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
@@ -234,6 +234,7 @@ const CjkInput = (() => {
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
_printableKeydownAt = null;
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
@@ -253,6 +254,7 @@ const CjkInput = (() => {
|
||||
_listeners.compositionstart = () => {
|
||||
_t(`compstart ${_vdesc(_textarea.value)}`);
|
||||
_composing = true;
|
||||
_printableKeydownAt = null;
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
@@ -277,6 +279,7 @@ const CjkInput = (() => {
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||
_printableKeydownAt = null;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
@@ -325,16 +328,11 @@ const CjkInput = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY.
|
||||
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
||||
// still enters the textarea and fires an input event — _keydownSentAt
|
||||
// tells the input handler to skip that echo.
|
||||
// A printable KeyboardEvent.key is the physical key, not necessarily
|
||||
// the committed text. Let the browser/IME produce the input event so
|
||||
// full-width punctuation and other layout transforms are preserved.
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_keydownSentText = e.key;
|
||||
_resetToPhantom();
|
||||
_printableKeydownAt = performance.now();
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -343,6 +341,8 @@ const CjkInput = (() => {
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
const printableKeydownAt = _printableKeydownAt;
|
||||
_printableKeydownAt = null;
|
||||
// ── Stuck-composition recovery ──
|
||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||
// matching compositionend. A stale _composing=true blocks every flush
|
||||
@@ -388,18 +388,18 @@ const CjkInput = (() => {
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — clear the textarea
|
||||
// echo that the IME inserted despite preventDefault. Content-checked:
|
||||
// only a value matching the sent char is an echo. Anything else (e.g.
|
||||
// an IME committing CJK text right after a keydown-sent char) is real
|
||||
// input and must flow through to the debounced flush, not be dropped.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
const cur = _strip(_textarea.value);
|
||||
if (cur === '' || cur === _keydownSentText) {
|
||||
_t('echo-drop');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// A recent physical printable key makes this insertText a keyboard
|
||||
// commit, so keep the old zero-latency path. Send the textarea's final
|
||||
// Unicode value, never KeyboardEvent.key, because the IME may have
|
||||
// transformed punctuation or the active layout may differ.
|
||||
if (
|
||||
e.inputType === 'insertText' &&
|
||||
printableKeydownAt !== null &&
|
||||
performance.now() - printableKeydownAt < 100
|
||||
) {
|
||||
_cancelDebouncedFlush();
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
@@ -425,6 +425,7 @@ const CjkInput = (() => {
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
_composing = false;
|
||||
_printableKeydownAt = null;
|
||||
_resetToPhantom();
|
||||
},
|
||||
|
||||
@@ -446,6 +447,7 @@ const CjkInput = (() => {
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_printableKeydownAt = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -54,6 +54,9 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
|
||||
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
|
||||
{ mode: 'pi', label: 'Pi', short: 'Pi' },
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
@@ -386,7 +389,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async resumeMobileOverviewSession(sessionId) {
|
||||
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
|
||||
if (!row || !row.workingDir) return;
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -579,6 +582,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
menu.appendChild(header);
|
||||
|
||||
for (const webview of this.webviews ? this.webviews.values() : []) {
|
||||
// Managed records are Codeman-owned shortcut state (the DeepSeek web UI
|
||||
// writes one), not saved dashboards: same filter as the desktop run menu,
|
||||
// or the phone picker lists a stale 127.0.0.1:<port> row that dies on the
|
||||
// next server restart with no affordance here to restart it.
|
||||
if (webview.managed) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'mobile-overview-run-option';
|
||||
|
||||
@@ -972,6 +972,51 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(244, 114, 182, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Grok mode colors on mobile. Same `!important` rationale as the pi block above. */
|
||||
.btn-toolbar.btn-run.mode-grok,
|
||||
.btn-toolbar.btn-run-gear.mode-grok {
|
||||
background: #1c1c1f !important;
|
||||
border-color: rgba(212, 212, 216, 0.3) !important;
|
||||
color: #f4f4f5 !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-grok:active,
|
||||
.btn-toolbar.btn-run-gear.mode-grok:active {
|
||||
background: #3f3f46 !important;
|
||||
border-color: rgba(212, 212, 216, 0.5) !important;
|
||||
}
|
||||
|
||||
/* DeepSeek mode colors on mobile. Same `!important` rationale as the pi and
|
||||
grok blocks above: styles.css nests its skin rules inside
|
||||
`html:not([data-skin="og"])`, so a bare `.btn-toolbar` rule there outranks a
|
||||
`.btn-toolbar.btn-x` rule here regardless of load order. */
|
||||
.btn-toolbar.btn-run.mode-deepseek,
|
||||
.btn-toolbar.btn-run-gear.mode-deepseek {
|
||||
background: #16225f !important;
|
||||
border-color: rgba(124, 147, 255, 0.35) !important;
|
||||
color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-deepseek:active,
|
||||
.btn-toolbar.btn-run-gear.mode-deepseek:active {
|
||||
background: #3350e6 !important;
|
||||
border-color: rgba(150, 170, 255, 0.55) !important;
|
||||
}
|
||||
|
||||
/* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */
|
||||
.btn-toolbar.btn-run.mode-omp,
|
||||
.btn-toolbar.btn-run-gear.mode-omp {
|
||||
background: #312e81 !important;
|
||||
border-color: rgba(129, 140, 248, 0.3) !important;
|
||||
color: #e0e7ff !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-omp:active,
|
||||
.btn-toolbar.btn-run-gear.mode-omp:active {
|
||||
background: #4f46e5 !important;
|
||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -3055,6 +3100,24 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
border-color: #4338ca;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-deepseek, .btn-toolbar.btn-run-gear.mode-deepseek) {
|
||||
background: linear-gradient(135deg, #2740c4, #4d6bfe);
|
||||
border-color: #1b2a8f;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
||||
border-left-color: var(--control-border-hover) !important;
|
||||
}
|
||||
|
||||
+761
-69
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi' };
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
@@ -670,7 +670,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -2980,54 +2980,423 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.setAttribute('aria-label', label);
|
||||
},
|
||||
|
||||
_ensureFileBrowserState() {
|
||||
if (!this._fileBrowserState) {
|
||||
const ownerSessionId = this.activeSessionId || null;
|
||||
const showHidden = this.fileBrowserShowHidden === true;
|
||||
this._fileBrowserState = {
|
||||
treeEpoch: 0,
|
||||
searchEpoch: 0,
|
||||
ownerSessionId,
|
||||
view: 'normal',
|
||||
normalState: this.fileBrowserData
|
||||
? { sessionId: ownerSessionId, showHidden, treeEpoch: 0, phase: 'ready', data: this.fileBrowserData }
|
||||
: null,
|
||||
treeInFlight: null,
|
||||
inFlight: null,
|
||||
matches: [],
|
||||
deferredDirectoryTarget: null,
|
||||
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
|
||||
};
|
||||
}
|
||||
return this._fileBrowserState;
|
||||
},
|
||||
|
||||
_activateFileBrowserSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
|
||||
const state = this._ensureFileBrowserState();
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
}
|
||||
state.searchEpoch++;
|
||||
state.treeEpoch++;
|
||||
state.ownerSessionId = sessionId;
|
||||
state.treeInFlight = null;
|
||||
state.inFlight = null;
|
||||
state.normalState = null;
|
||||
state.matches = [];
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
this.fileBrowserAllExpanded = false;
|
||||
|
||||
const searchInput = this.$?.('fileBrowserSearch');
|
||||
if (searchInput) searchInput.value = '';
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
|
||||
const panel = this.$?.('fileBrowserPanel');
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
const statusEl = this.$?.('fileBrowserStatus');
|
||||
const visible = panel?.classList.contains('visible') === true;
|
||||
if (treeEl) {
|
||||
treeEl.innerHTML = visible
|
||||
? `<div class="file-browser-loading">${escapeHtml('Loading files...')}</div>`
|
||||
: '';
|
||||
}
|
||||
if (statusEl) statusEl.textContent = visible ? 'Loading files...' : '';
|
||||
|
||||
if (visible) {
|
||||
const load = this.loadFileBrowser?.(sessionId);
|
||||
load?.catch?.(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
_resetFileBrowserForHide() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
}
|
||||
state.searchEpoch++;
|
||||
state.treeEpoch++;
|
||||
state.treeInFlight = null;
|
||||
state.inFlight = null;
|
||||
state.normalState = null;
|
||||
state.matches = [];
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
this.fileBrowserAllExpanded = false;
|
||||
|
||||
const searchInput = this.$?.('fileBrowserSearch');
|
||||
if (searchInput) searchInput.value = '';
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
if (treeEl) treeEl.innerHTML = '';
|
||||
const statusEl = this.$?.('fileBrowserStatus');
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
},
|
||||
|
||||
_setFileBrowserExpandDisabled(disabled) {
|
||||
const btn = this.$('fileBrowserExpandBtn');
|
||||
if (btn) btn.disabled = disabled;
|
||||
},
|
||||
|
||||
_hasFileBrowserQuery() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const input = this.$?.('fileBrowserSearch');
|
||||
const inputValue = typeof input?.value === 'string' ? input.value : '';
|
||||
const filterValue = typeof state.filter === 'string' ? state.filter : '';
|
||||
return inputValue.trim() !== '' || filterValue.trim() !== '';
|
||||
},
|
||||
|
||||
_syncFileBrowserExpandBtn() {
|
||||
this._setFileBrowserExpandDisabled(this._hasFileBrowserQuery());
|
||||
},
|
||||
|
||||
_renderFileBrowserNormalStatus(data, showHidden) {
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
if (!statusEl || !data) return;
|
||||
const { totalFiles, totalDirectories, truncated } = data;
|
||||
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
|
||||
},
|
||||
|
||||
_isFileBrowserNormalCompatible(candidate, sessionId, showHidden, treeEpoch) {
|
||||
return (
|
||||
candidate?.sessionId === sessionId &&
|
||||
candidate.showHidden === showHidden &&
|
||||
candidate.treeEpoch === treeEpoch
|
||||
);
|
||||
},
|
||||
|
||||
_isFileBrowserTreeContextCurrent(request, requireCurrentRecord = false) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
return (
|
||||
(!requireCurrentRecord || state.treeInFlight === request) &&
|
||||
state.ownerSessionId === request.sessionId &&
|
||||
state.treeEpoch === request.treeEpoch &&
|
||||
(this.fileBrowserShowHidden === true) === request.showHidden
|
||||
);
|
||||
},
|
||||
|
||||
_canRenderFileBrowserNormal(normalState) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
return (
|
||||
state.view === 'normal' &&
|
||||
this.activeSessionId === normalState?.sessionId &&
|
||||
this._isFileBrowserNormalCompatible(
|
||||
normalState,
|
||||
state.ownerSessionId,
|
||||
this.fileBrowserShowHidden === true,
|
||||
state.treeEpoch,
|
||||
) &&
|
||||
this.$('fileBrowserPanel')?.classList.contains('visible') === true
|
||||
);
|
||||
},
|
||||
|
||||
_renderFileBrowserNormalState(normalState) {
|
||||
if (!normalState || !this._canRenderFileBrowserNormal(normalState)) return;
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
if (!treeEl) return;
|
||||
|
||||
if (normalState.phase === 'loading') {
|
||||
this.fileBrowserData = null;
|
||||
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Loading files...')}</div>`;
|
||||
if (statusEl) statusEl.textContent = 'Loading files...';
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalState.phase === 'error') {
|
||||
this.fileBrowserData = null;
|
||||
const detail = normalState.error && normalState.error !== 'Failed to load files'
|
||||
? `: ${normalState.error}`
|
||||
: '';
|
||||
const message = `Failed to load files${detail}`;
|
||||
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalState.phase !== 'ready') return;
|
||||
this.fileBrowserData = normalState.data;
|
||||
this._syncFileBrowserExpandBtn();
|
||||
this.renderFileBrowserTree(normalState.sessionId);
|
||||
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||
},
|
||||
|
||||
_validateFileBrowserTreeEnvelope(result) {
|
||||
if (!result || typeof result !== 'object' || result.success !== true) return null;
|
||||
const data = result.data;
|
||||
if (!data || typeof data !== 'object' || !Array.isArray(data.tree)) return null;
|
||||
if (data.mode === 'search') return null;
|
||||
if (
|
||||
typeof data.totalFiles !== 'number' ||
|
||||
!Number.isFinite(data.totalFiles) ||
|
||||
data.totalFiles < 0 ||
|
||||
typeof data.totalDirectories !== 'number' ||
|
||||
!Number.isFinite(data.totalDirectories) ||
|
||||
data.totalDirectories < 0 ||
|
||||
typeof data.truncated !== 'boolean'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validNodes = nodes => nodes.every(node => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
if (typeof node.name !== 'string' || typeof node.path !== 'string') return false;
|
||||
if (node.type !== 'file' && node.type !== 'directory') return false;
|
||||
if (node.size !== undefined && (typeof node.size !== 'number' || !Number.isFinite(node.size))) return false;
|
||||
if (node.extension !== undefined && typeof node.extension !== 'string') return false;
|
||||
if (node.children !== undefined && (!Array.isArray(node.children) || !validNodes(node.children))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return validNodes(data.tree) ? data : null;
|
||||
},
|
||||
|
||||
_normalizeFileBrowserTreeError(error) {
|
||||
return typeof error?.message === 'string' && error.message ? error.message : 'Failed to load files';
|
||||
},
|
||||
|
||||
_validateFileBrowserSearchEnvelope(result) {
|
||||
if (!result || typeof result !== 'object' || result.success !== true) return null;
|
||||
const data = result.data;
|
||||
if (!data || typeof data !== 'object' || data.mode !== 'search' || !Array.isArray(data.matches)) return null;
|
||||
if (typeof data.truncated !== 'boolean') return null;
|
||||
if (
|
||||
data.matchCount !== undefined &&
|
||||
(typeof data.matchCount !== 'number' || !Number.isFinite(data.matchCount) || data.matchCount < 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const match of data.matches) {
|
||||
if (!match || typeof match !== 'object') return null;
|
||||
if (typeof match.name !== 'string' || typeof match.path !== 'string') return null;
|
||||
if (match.type !== 'file' && match.type !== 'directory') return null;
|
||||
if (match.size !== undefined && (typeof match.size !== 'number' || !Number.isFinite(match.size))) return null;
|
||||
if (match.extension !== undefined && typeof match.extension !== 'string') return null;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
_canRenderFileBrowserSearch(request) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
return (
|
||||
state.searchEpoch === request.epoch &&
|
||||
state.ownerSessionId === request.ownerSessionId &&
|
||||
this.activeSessionId === request.ownerSessionId &&
|
||||
(this.fileBrowserShowHidden === true) === request.showHidden &&
|
||||
state.filter === request.rawInput &&
|
||||
panel?.classList.contains('visible') === true
|
||||
);
|
||||
},
|
||||
|
||||
_renderFileBrowserSearchError() {
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
const message = 'Search failed';
|
||||
if (treeEl) treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
},
|
||||
|
||||
_canContinueFileBrowserHiddenReload(continuation, normalState) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const input = this.$?.('fileBrowserSearch');
|
||||
const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
|
||||
return (
|
||||
state.view === 'normal' &&
|
||||
state.searchEpoch === continuation.searchEpoch &&
|
||||
state.treeEpoch === continuation.treeEpoch &&
|
||||
state.ownerSessionId === continuation.ownerSessionId &&
|
||||
this.activeSessionId === continuation.ownerSessionId &&
|
||||
(this.fileBrowserShowHidden === true) === continuation.showHidden &&
|
||||
state.filter === continuation.rawInput &&
|
||||
currentInput === continuation.rawInput &&
|
||||
currentInput.trim() === continuation.query &&
|
||||
this.$?.('fileBrowserPanel')?.classList.contains('visible') === true &&
|
||||
normalState?.phase === 'ready' &&
|
||||
this._isFileBrowserNormalCompatible(
|
||||
normalState,
|
||||
continuation.ownerSessionId,
|
||||
continuation.showHidden,
|
||||
continuation.treeEpoch,
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
async toggleFileBrowserHidden() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
}
|
||||
state.searchEpoch++;
|
||||
state.inFlight = null;
|
||||
state.matches = [];
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.normalState = null;
|
||||
this.fileBrowserData = null;
|
||||
if (query.length <= 256) state.view = 'normal';
|
||||
this._syncFileBrowserExpandBtn();
|
||||
|
||||
// Expanded-directory state is deliberately preserved so toggling does not
|
||||
// collapse the tree the user just navigated.
|
||||
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
|
||||
},
|
||||
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
|
||||
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return;
|
||||
|
||||
async loadFileBrowser(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const searchEpoch = state.searchEpoch;
|
||||
const showHidden = this.fileBrowserShowHidden === true;
|
||||
const load = this.loadFileBrowser(ownerSessionId, { force: true });
|
||||
const treeEpoch = state.treeEpoch;
|
||||
if (!load?.then) return;
|
||||
await load;
|
||||
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
if (!treeEl) return;
|
||||
|
||||
// Show loading state
|
||||
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
|
||||
|
||||
try {
|
||||
const showHidden = this.fileBrowserShowHidden === true;
|
||||
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
|
||||
const result = await res.json();
|
||||
if (!result.success) throw new Error(result.error || 'Failed to load files');
|
||||
|
||||
this.fileBrowserData = result.data;
|
||||
this.renderFileBrowserTree();
|
||||
|
||||
// Update status
|
||||
if (statusEl) {
|
||||
const { totalFiles, totalDirectories, truncated } = result.data;
|
||||
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load file browser:', err);
|
||||
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${escapeHtml(err.message)}</div>`;
|
||||
if (!query || query.length > 256) return;
|
||||
const continuation = { ownerSessionId, showHidden, treeEpoch, searchEpoch, rawInput, query };
|
||||
if (this._canContinueFileBrowserHiddenReload(continuation, state.normalState)) {
|
||||
this.filterFileBrowser(rawInput);
|
||||
}
|
||||
},
|
||||
|
||||
renderFileBrowserTree() {
|
||||
loadFileBrowser(sessionId, { force = false } = {}) {
|
||||
if (!sessionId) return undefined;
|
||||
|
||||
const state = this._ensureFileBrowserState();
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
if (!treeEl) return undefined;
|
||||
if (!state.ownerSessionId) state.ownerSessionId = sessionId;
|
||||
if (state.ownerSessionId !== sessionId) return undefined;
|
||||
|
||||
if (force) state.treeEpoch++;
|
||||
const showHidden = this.fileBrowserShowHidden === true;
|
||||
const treeEpoch = state.treeEpoch;
|
||||
const inFlight = state.treeInFlight;
|
||||
if (
|
||||
!force &&
|
||||
this._isFileBrowserNormalCompatible(inFlight, sessionId, showHidden, treeEpoch)
|
||||
) {
|
||||
return inFlight.promise;
|
||||
}
|
||||
|
||||
const settled = state.normalState;
|
||||
if (
|
||||
!force &&
|
||||
this._isFileBrowserNormalCompatible(settled, sessionId, showHidden, treeEpoch) &&
|
||||
(settled.phase === 'ready' || settled.phase === 'error')
|
||||
) {
|
||||
if (settled.phase === 'ready') this.fileBrowserData = settled.data;
|
||||
this._renderFileBrowserNormalState(settled);
|
||||
return Promise.resolve(settled);
|
||||
}
|
||||
|
||||
const loadingState = { sessionId, showHidden, treeEpoch, phase: 'loading' };
|
||||
state.normalState = loadingState;
|
||||
this.fileBrowserData = null;
|
||||
this._renderFileBrowserNormalState(loadingState);
|
||||
|
||||
const record = { sessionId, showHidden, treeEpoch, promise: null };
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
const result = await res.json();
|
||||
const data = this._validateFileBrowserTreeEnvelope(result);
|
||||
if (!data) {
|
||||
const detail = result && typeof result === 'object' && typeof result.error === 'string'
|
||||
? result.error
|
||||
: 'Failed to load files';
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (!this._isFileBrowserTreeContextCurrent(record, true)) return;
|
||||
|
||||
const nextNormalState = { sessionId, showHidden, treeEpoch, phase: 'ready', data };
|
||||
state.normalState = nextNormalState;
|
||||
this.fileBrowserData = data;
|
||||
const deferredRendered = this._completeDeferredFileBrowserDirectory?.(nextNormalState) === true;
|
||||
if (!deferredRendered) this._renderFileBrowserNormalState(nextNormalState);
|
||||
} catch (error) {
|
||||
if (!this._isFileBrowserTreeContextCurrent(record, true)) return;
|
||||
const nextNormalState = {
|
||||
sessionId,
|
||||
showHidden,
|
||||
treeEpoch,
|
||||
phase: 'error',
|
||||
error: this._normalizeFileBrowserTreeError(error),
|
||||
};
|
||||
state.normalState = nextNormalState;
|
||||
this.fileBrowserData = null;
|
||||
this._completeDeferredFileBrowserDirectory?.(nextNormalState);
|
||||
console.error('Failed to load file browser:', error);
|
||||
this._renderFileBrowserNormalState(nextNormalState);
|
||||
}
|
||||
})();
|
||||
record.promise = request.finally(() => {
|
||||
if (state.treeInFlight === record) state.treeInFlight = null;
|
||||
});
|
||||
state.treeInFlight = record;
|
||||
return record.promise;
|
||||
},
|
||||
|
||||
renderFileBrowserTree(ownerSessionId) {
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
if (!treeEl || !this.fileBrowserData) return;
|
||||
|
||||
const state = this._ensureFileBrowserState();
|
||||
const owner = ownerSessionId || state.normalState?.sessionId || state.ownerSessionId || this.activeSessionId;
|
||||
if (!owner) return;
|
||||
|
||||
const { tree } = this.fileBrowserData;
|
||||
if (!tree || tree.length === 0) {
|
||||
treeEl.innerHTML = '<div class="file-browser-empty">No files found</div>';
|
||||
@@ -3035,21 +3404,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const html = [];
|
||||
const filter = this.fileBrowserFilter.toLowerCase();
|
||||
|
||||
const renderNode = (node, depth) => {
|
||||
const isDir = node.type === 'directory';
|
||||
const isExpanded = this.fileBrowserExpandedDirs.has(node.path);
|
||||
const matchesFilter = !filter || node.name.toLowerCase().includes(filter);
|
||||
|
||||
// For directories, check if any children match
|
||||
let hasMatchingChildren = false;
|
||||
if (isDir && filter && node.children) {
|
||||
hasMatchingChildren = this.hasMatchingChild(node, filter);
|
||||
}
|
||||
|
||||
const shouldShow = matchesFilter || hasMatchingChildren;
|
||||
const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : '';
|
||||
|
||||
const icon = isDir
|
||||
? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1')
|
||||
@@ -3066,11 +3424,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
||||
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
|
||||
${expandIcon}
|
||||
<span class="file-tree-icon">${icon}</span>
|
||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||
@@ -3102,21 +3460,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (type === 'directory') {
|
||||
this.toggleFileBrowserFolder(path);
|
||||
} else {
|
||||
this.openFilePreview(path);
|
||||
this.openFilePreview(path, owner);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
hasMatchingChild(node, filter) {
|
||||
if (!node.children) return false;
|
||||
for (const child of node.children) {
|
||||
if (child.name.toLowerCase().includes(filter)) return true;
|
||||
if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
toggleFileBrowserFolder(path) {
|
||||
if (this.fileBrowserExpandedDirs.has(path)) {
|
||||
this.fileBrowserExpandedDirs.delete(path);
|
||||
@@ -3127,12 +3476,291 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
filterFileBrowser(value) {
|
||||
this.fileBrowserFilter = value;
|
||||
// Auto-expand all if filtering
|
||||
if (value) {
|
||||
this.expandAllDirectories(this.fileBrowserData?.tree || []);
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = String(value ?? '');
|
||||
const query = rawInput.trim();
|
||||
state.searchEpoch++;
|
||||
state.filter = rawInput;
|
||||
state.deferredDirectoryTarget = null;
|
||||
this.fileBrowserFilter = rawInput;
|
||||
this._syncFileBrowserExpandBtn();
|
||||
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
}
|
||||
this.renderFileBrowserTree();
|
||||
state.inFlight = null;
|
||||
|
||||
if (!state.ownerSessionId && this.activeSessionId) state.ownerSessionId = this.activeSessionId;
|
||||
const ownerSessionId = state.ownerSessionId || null;
|
||||
if (!this.activeSessionId || !ownerSessionId || this.activeSessionId !== ownerSessionId) return;
|
||||
if (!query) {
|
||||
state.view = 'normal';
|
||||
state.matches = [];
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const normal = state.normalState;
|
||||
if (
|
||||
ownerSessionId &&
|
||||
this._isFileBrowserNormalCompatible(
|
||||
normal,
|
||||
ownerSessionId,
|
||||
this.fileBrowserShowHidden === true,
|
||||
state.treeEpoch,
|
||||
)
|
||||
) {
|
||||
this._renderFileBrowserNormalState(normal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length > 256) {
|
||||
const message = 'Search queries are limited to 256 characters';
|
||||
state.view = 'query-error';
|
||||
state.matches = [];
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
if (treeEl) treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
if (!ownerSessionId || !panel || !treeEl) return;
|
||||
|
||||
const request = {
|
||||
epoch: state.searchEpoch,
|
||||
treeEpoch: state.treeEpoch,
|
||||
ownerSessionId,
|
||||
showHidden: this.fileBrowserShowHidden === true,
|
||||
rawInput,
|
||||
query,
|
||||
timer: null,
|
||||
};
|
||||
state.view = 'search-pending';
|
||||
state.matches = [];
|
||||
state.inFlight = request;
|
||||
this._syncFileBrowserExpandBtn();
|
||||
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Searching...')}</div>`;
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
if (statusEl) statusEl.textContent = 'Searching...';
|
||||
|
||||
request.timer = setTimeout(async () => {
|
||||
request.timer = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const result = await res.json();
|
||||
const data = this._validateFileBrowserSearchEnvelope(result);
|
||||
if (!data) throw new Error('Search failed');
|
||||
const canRender = this._canRenderFileBrowserSearch(request);
|
||||
if (state.inFlight === request) state.inFlight = null;
|
||||
if (!canRender) return;
|
||||
state.view = 'search-results';
|
||||
state.matches = data.matches;
|
||||
this._renderFileBrowserSearchResults(data.matches, ownerSessionId, data);
|
||||
} catch (err) {
|
||||
const canRender = this._canRenderFileBrowserSearch(request);
|
||||
if (state.inFlight === request) state.inFlight = null;
|
||||
if (!canRender) return;
|
||||
console.error('Failed to search file browser:', err);
|
||||
state.view = 'search-error';
|
||||
state.matches = [];
|
||||
this._renderFileBrowserSearchError();
|
||||
}
|
||||
}, 250);
|
||||
},
|
||||
|
||||
_renderFileBrowserSearchResults(matches, ownerSessionId, data) {
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
if (!treeEl || !ownerSessionId) return;
|
||||
const state = this._ensureFileBrowserState();
|
||||
const searchContext = {
|
||||
ownerSessionId,
|
||||
showHidden: this.fileBrowserShowHidden === true,
|
||||
treeEpoch: state.treeEpoch,
|
||||
searchEpoch: state.searchEpoch,
|
||||
rawInput: state.filter,
|
||||
query: state.filter.trim(),
|
||||
view: state.view,
|
||||
};
|
||||
if (matches.length === 0) {
|
||||
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
|
||||
} else {
|
||||
const ownerPath = encodeURIComponent(ownerSessionId);
|
||||
treeEl.innerHTML = matches
|
||||
.map(match => {
|
||||
const isDir = match.type === 'directory';
|
||||
const icon = isDir ? '📁' : this.getFileIcon(match.extension || '');
|
||||
const sizeStr = !isDir && match.size !== undefined
|
||||
? `<span class="file-tree-size">${this.formatFileSize(match.size)}</span>`
|
||||
: '';
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
return `
|
||||
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
|
||||
<span class="file-tree-expand"></span>
|
||||
<span class="file-tree-icon">${icon}</span>
|
||||
<span class="${nameClass}">${escapeHtml(match.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
treeEl.querySelectorAll('.file-tree-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const path = item.dataset.path;
|
||||
if (item.dataset.type === 'directory') {
|
||||
this._openFileBrowserSearchDirectory({ ...searchContext, path });
|
||||
} else {
|
||||
this.openFilePreview(path, ownerSessionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
if (statusEl) {
|
||||
const count = data.matchCount === undefined ? matches.length : data.matchCount;
|
||||
statusEl.textContent = `${count} ${count === 1 ? 'match' : 'matches'}${data.truncated ? ' (truncated)' : ''}`;
|
||||
}
|
||||
},
|
||||
|
||||
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
|
||||
if (!Array.isArray(nodes)) return null;
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
if (node.type === 'directory' && node.path === targetPath) {
|
||||
return { target: node, ancestors: [...ancestors] };
|
||||
}
|
||||
if (node.type !== 'directory' || !Array.isArray(node.children)) continue;
|
||||
const found = this._findFileBrowserDirectory(node.children, targetPath, [...ancestors, node.path]);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
_isFileBrowserDirectoryContextCurrent(target) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const input = this.$?.('fileBrowserSearch');
|
||||
const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
|
||||
return (
|
||||
target &&
|
||||
state.ownerSessionId === target.ownerSessionId &&
|
||||
this.activeSessionId === target.ownerSessionId &&
|
||||
state.treeEpoch === target.treeEpoch &&
|
||||
state.searchEpoch === target.searchEpoch &&
|
||||
(this.fileBrowserShowHidden === true) === target.showHidden &&
|
||||
state.filter === target.rawInput &&
|
||||
currentInput === target.rawInput &&
|
||||
currentInput.trim() === target.query &&
|
||||
state.view === target.view &&
|
||||
this.$?.('fileBrowserPanel')?.classList.contains('visible') === true
|
||||
);
|
||||
},
|
||||
|
||||
_promptFileBrowserDirectoryReload() {
|
||||
this.showToast?.('Reload files before opening this folder', 'info');
|
||||
},
|
||||
|
||||
_openFileBrowserSearchDirectory(target) {
|
||||
if (!this._isFileBrowserDirectoryContextCurrent(target)) return;
|
||||
const state = this._ensureFileBrowserState();
|
||||
const normalState = state.normalState;
|
||||
if (
|
||||
!this._isFileBrowserNormalCompatible(
|
||||
normalState,
|
||||
target.ownerSessionId,
|
||||
target.showHidden,
|
||||
target.treeEpoch,
|
||||
)
|
||||
) {
|
||||
state.deferredDirectoryTarget = null;
|
||||
this._promptFileBrowserDirectoryReload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalState.phase === 'loading') {
|
||||
state.deferredDirectoryTarget = { ...target };
|
||||
return;
|
||||
}
|
||||
|
||||
state.deferredDirectoryTarget = null;
|
||||
if (normalState.phase !== 'ready') {
|
||||
this._promptFileBrowserDirectoryReload();
|
||||
return;
|
||||
}
|
||||
|
||||
const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
|
||||
if (!found) {
|
||||
this._promptFileBrowserDirectoryReload();
|
||||
return;
|
||||
}
|
||||
this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
|
||||
},
|
||||
|
||||
_completeDeferredFileBrowserDirectory(normalState) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const target = state.deferredDirectoryTarget;
|
||||
if (!target) return false;
|
||||
if (!this._isFileBrowserDirectoryContextCurrent(target)) {
|
||||
if (state.deferredDirectoryTarget === target) state.deferredDirectoryTarget = null;
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this._isFileBrowserNormalCompatible(
|
||||
normalState,
|
||||
target.ownerSessionId,
|
||||
target.showHidden,
|
||||
target.treeEpoch,
|
||||
) ||
|
||||
(normalState.phase !== 'ready' && normalState.phase !== 'error')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.deferredDirectoryTarget = null;
|
||||
if (normalState.phase === 'error') {
|
||||
this._promptFileBrowserDirectoryReload();
|
||||
return false;
|
||||
}
|
||||
|
||||
const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
|
||||
if (!found) {
|
||||
this._promptFileBrowserDirectoryReload();
|
||||
return false;
|
||||
}
|
||||
this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
|
||||
return true;
|
||||
},
|
||||
|
||||
_leaveFileBrowserSearchForDirectory(paths, normalState) {
|
||||
const state = this._ensureFileBrowserState();
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
}
|
||||
state.searchEpoch++;
|
||||
state.inFlight = null;
|
||||
state.filter = '';
|
||||
state.matches = [];
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.view = 'normal';
|
||||
this.fileBrowserFilter = '';
|
||||
|
||||
const input = this.$?.('fileBrowserSearch');
|
||||
if (input) input.value = '';
|
||||
this._syncFileBrowserExpandBtn();
|
||||
for (const path of paths) {
|
||||
if (typeof path === 'string') this.fileBrowserExpandedDirs?.add?.(path);
|
||||
}
|
||||
this.fileBrowserData = normalState.data;
|
||||
this._renderFileBrowserNormalState(normalState);
|
||||
},
|
||||
|
||||
expandAllDirectories(nodes) {
|
||||
@@ -3151,6 +3779,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
toggleFileBrowserExpand() {
|
||||
if (this._hasFileBrowserQuery()) {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
return;
|
||||
}
|
||||
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
|
||||
const btn = this.$('fileBrowserExpandBtn');
|
||||
|
||||
@@ -3165,14 +3797,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
refreshFileBrowser() {
|
||||
if (this.activeSessionId) {
|
||||
this.fileBrowserExpandedDirs.clear();
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserAllExpanded = false;
|
||||
const searchInput = this.$('fileBrowserSearch');
|
||||
if (searchInput) searchInput.value = '';
|
||||
this.loadFileBrowser(this.activeSessionId);
|
||||
const state = this._ensureFileBrowserState();
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
}
|
||||
state.inFlight = null;
|
||||
state.searchEpoch++;
|
||||
state.filter = '';
|
||||
state.matches = [];
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.view = 'normal';
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs.clear();
|
||||
this.fileBrowserAllExpanded = false;
|
||||
const expandBtn = this.$('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
const searchInput = this.$('fileBrowserSearch');
|
||||
if (searchInput) searchInput.value = '';
|
||||
this._syncFileBrowserExpandBtn();
|
||||
|
||||
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
|
||||
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined;
|
||||
return this.loadFileBrowser(ownerSessionId, { force: true });
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
@@ -3203,6 +3849,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
this._resetFileBrowserForHide();
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset position so it reopens at default location
|
||||
@@ -3313,6 +3960,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Stop whatever the previous preview was playing. Overwriting innerHTML
|
||||
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
|
||||
this._stopFilePreviewMedia();
|
||||
// Disarm detach until this load has a URL of its own: an early error return
|
||||
// must not leave the button opening the PREVIOUS file in a new tab.
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
@@ -3340,6 +3992,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every branch below renders from one of these routes, so the detach button
|
||||
// can always offer the same bytes in a browser tab: docx/pptx through the
|
||||
// server-converted PDF preview, everything else through the raw route.
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
this.filePreviewDetachUrl = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
@@ -3489,6 +4154,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
// audible and keeps streaming from the server. Closing has to stop it.
|
||||
this._stopFilePreviewMedia();
|
||||
this.filePreviewContent = '';
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the previewed file in a browser tab and close the overlay.
|
||||
*
|
||||
* window.open is called WITHOUT the 'noopener' feature string: with it the
|
||||
* call returns null even on success, which would make a blocked pop-up
|
||||
* indistinguishable from a working one. The opener link is severed by hand
|
||||
* instead, and a null return then reliably means the browser blocked it, in
|
||||
* which case the overlay stays up so the user has not lost the file.
|
||||
*/
|
||||
detachFilePreview() {
|
||||
if (!this.filePreviewDetachUrl) return;
|
||||
const win = window.open(this.filePreviewDetachUrl, '_blank');
|
||||
if (!win) {
|
||||
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
|
||||
return;
|
||||
}
|
||||
win.opener = null;
|
||||
this.closeFilePreview();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4127,6 +4815,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}).catch(() => {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
});
|
||||
} else {
|
||||
// Media/PDF/binary previews have no text buffer. Saying so beats the
|
||||
// dead-button silence this used to be.
|
||||
this.showToast('Nothing to copy in this preview', 'info');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -160,6 +160,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
const stripRect = strip.getBoundingClientRect();
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
for (const edge of edges) {
|
||||
for (const id of [edge.parentId, edge.childId]) {
|
||||
const key = 'tab:' + id;
|
||||
@@ -175,7 +177,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const childRect = rects.get('tab:' + edge.childId);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({ parent: parentRect, child: childRect, strip: stripRect, depth: edge.depth });
|
||||
const geom = compute({
|
||||
parent: parentRect,
|
||||
child: childRect,
|
||||
strip: stripRect,
|
||||
depth: edge.depth,
|
||||
orientation,
|
||||
});
|
||||
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
@@ -222,10 +230,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
this._lineageScrollHandler = () => {
|
||||
// Sidebar layout scrolls the SAME element vertically, and there the
|
||||
// subagent/ultracode connectors anchor to tab rects too (lineage arcs are
|
||||
// skipped, so _lineageEdgeCount alone would never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive?.()) this.updateConnectionLines();
|
||||
// Sidebar layout and the vertical rail scroll the SAME element
|
||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
||||
// would never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||
},
|
||||
|
||||
+399
-18
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi),
|
||||
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek),
|
||||
* session options modal (per-session settings, color picker, rename),
|
||||
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
||||
* create case modal, and mobile case picker.
|
||||
@@ -400,9 +400,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
return await this.runOmp();
|
||||
}
|
||||
if (mode === 'pi') {
|
||||
return await this.runPi();
|
||||
}
|
||||
if (mode === 'grok') {
|
||||
return await this.runGrok();
|
||||
}
|
||||
if (mode === 'deepseek') {
|
||||
return await this.runDeepSeek();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
@@ -468,10 +477,149 @@ Object.assign(CodemanApp.prototype, {
|
||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
}
|
||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
||||
// ships no terminal front door. In that state the honest offer is "add one",
|
||||
// not a hidden entry with no explanation anywhere.
|
||||
const avail = window.__codemanCliAvailable || {};
|
||||
const dsInstall = menu.querySelector('#runModeDeepSeekInstall');
|
||||
if (dsInstall) {
|
||||
dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none';
|
||||
}
|
||||
// The web UI needs only the BINARY: it is the one interactive surface
|
||||
// DeepSeek ships itself, so it works on a box with no terminal profile at
|
||||
// all (and is the honest thing to offer there).
|
||||
const dsWeb = menu.querySelector('#runModeDeepSeekWeb');
|
||||
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
|
||||
*
|
||||
* The server is a background child process owned by
|
||||
* `deepseek-web-server.ts`, NOT a shell session. It was a shell session first,
|
||||
* on the reasoning that Codeman already supervises those, and that version
|
||||
* worked - it just put a terminal tab on screen beside the web tab the user
|
||||
* actually asked for, on every click. Opening a dashboard should open one tab.
|
||||
*
|
||||
* `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a
|
||||
* browser-trust check on the request authority, and a Codeman web tab reaches
|
||||
* it through Codeman's own origin via the webview proxy, not directly. Without
|
||||
* passing Codeman's authority the page renders and every API call fails.
|
||||
*
|
||||
* The tab is saved `trusted: true`, and that is REQUIRED rather than a
|
||||
* convenience: an untrusted webview is sandboxed without `allow-same-origin`,
|
||||
* which breaks this dashboard twice over. The dsh client-runtime reads
|
||||
* `localStorage` while loading its plugins and dies there ("the document is
|
||||
* sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin
|
||||
* frame sends `Origin: null`, so dsh's own trust check 403s every `/api` call
|
||||
* no matter which authority `--trusted-host` names. Passing `location.host`
|
||||
* only means anything once the frame actually carries that origin.
|
||||
*
|
||||
* The trade this makes is real and worth stating: a trusted proxied frame is
|
||||
* same-origin with Codeman and can therefore reach Codeman's own API. It is
|
||||
* defensible only because of what this specific dashboard already is - an
|
||||
* agent harness Codeman just started itself, on loopback, which can run code
|
||||
* as the user regardless. It is not a precedent for trusting third-party
|
||||
* dashboards generally, which is why it is set here rather than defaulted.
|
||||
*/
|
||||
async runDeepSeekWeb() {
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...');
|
||||
|
||||
try {
|
||||
// One request, and the server owns everything behind it: picking a free
|
||||
// port, spawning, waiting for the port to answer, and reusing an already
|
||||
// running server instead of racing it. This used to start the server in a
|
||||
// shell SESSION, which worked but put a terminal tab on screen next to the
|
||||
// web tab actually asked for, every single time.
|
||||
//
|
||||
// `authority` is what dsh fences its own `/api` behind (`--trusted-host`),
|
||||
// so it must be the origin this page is loaded from rather than anything
|
||||
// the server could guess: a Codeman reachable at both loopback and a
|
||||
// tailnet name has two, and only the browser knows which one is in play.
|
||||
const startRes = await fetch('/api/deepseek/web', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ authority: location.host }),
|
||||
});
|
||||
const startData = await startRes.json();
|
||||
if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI');
|
||||
const url = startData.data.url;
|
||||
|
||||
// One managed record, repointed rather than duplicated: the port is chosen
|
||||
// per launch, so creating a fresh row each time would stack a dashboard
|
||||
// per restart, each pointing at a port nothing serves any more.
|
||||
let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web');
|
||||
if (webview) {
|
||||
const patchRes = await fetch(`/api/webviews/${webview.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, trusted: true }),
|
||||
});
|
||||
const patchData = await patchRes.json();
|
||||
if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab');
|
||||
webview = patchData.data.webview || patchData.data;
|
||||
} else {
|
||||
const wvRes = await fetch('/api/webviews', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'DeepSeek Harness',
|
||||
url,
|
||||
icon: '\u{1F433}',
|
||||
managed: 'deepseek-web',
|
||||
trusted: true,
|
||||
}),
|
||||
});
|
||||
const wvData = await wvRes.json();
|
||||
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
|
||||
webview = wvData.data.webview || wvData.data;
|
||||
}
|
||||
// refreshWebviews, not a hopeful optional-chain: openWebview() reads
|
||||
// this.webviews and silently no-ops on an id it has not loaded, so
|
||||
// skipping the refresh made the FIRST click create the record but open
|
||||
// nothing (the SSE round-trip had not landed yet).
|
||||
await this.refreshWebviews?.();
|
||||
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`);
|
||||
if (webview?.id) await this.openWebview(webview.id);
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Install a DeepSeek Harness terminal profile from the run menu.
|
||||
*
|
||||
* Held open for as long as the package manager takes (the endpoint bounds it),
|
||||
* so the button reports progress rather than appearing to do nothing. On
|
||||
* success the availability map is patched in place, which is what makes the
|
||||
* real DeepSeek entry appear without a reload.
|
||||
*/
|
||||
async installDeepSeekProfile() {
|
||||
const label = 'Installing a DeepSeek terminal profile (this can take a minute)...';
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(label);
|
||||
try {
|
||||
const res = await fetch('/api/deepseek/install-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to install the profile');
|
||||
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable };
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`);
|
||||
this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success');
|
||||
const menu = document.getElementById('runModeMenu');
|
||||
if (menu) this._refreshRunModeAvailability(menu);
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
@@ -544,7 +692,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(...parts);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -565,7 +713,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1278,6 +1426,194 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runOmp() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
||||
// and the local-only config below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/omp/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'omp',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start OMP');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Grok Build (xAI `grok`) session.
|
||||
*
|
||||
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
|
||||
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
* work, so the Run button opts into grok's bypassPermissions mode
|
||||
* (`--always-approve`; config-level deny rules still apply on top). The
|
||||
* multi-user clamp forces it back off for non-granted owners server-side.
|
||||
*/
|
||||
async runGrok() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Grok session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/grok/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'grok',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
grokConfig: { alwaysApprove: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Grok');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a DeepSeek Harness (`dsh`) session.
|
||||
*
|
||||
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
||||
* autonomous work. The harness has no bypass FLAG, so this rides the
|
||||
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
||||
* back down to `workspace-write` for non-granted owners server-side.
|
||||
*
|
||||
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
||||
* output-stabilization guessing to definitive idle/blocked hook events.
|
||||
*
|
||||
* The two-part availability check is deliberate. `dsh` being installed is not
|
||||
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
||||
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
||||
* command that fixes it, is the difference between "the Run button is broken"
|
||||
* and a 30-second fix.
|
||||
*/
|
||||
async runDeepSeek() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/deepseek/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!status.runnable) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'deepseek',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Options Modal
|
||||
@@ -1343,7 +1679,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -1373,7 +1709,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
@@ -1788,15 +2124,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this._activeRename?.cancel();
|
||||
|
||||
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
||||
if (!tabName) return;
|
||||
|
||||
// Prevent tab re-renders from destroying the input while renaming
|
||||
this._inlineRenameActive = true;
|
||||
tabName.classList.add('tab-name-renaming');
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
const originalContent = tabName.textContent;
|
||||
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
|
||||
const restoreOriginalChildren = () => {
|
||||
tabName.replaceChildren(...originalChildren.map((node) => node.cloneNode(true)));
|
||||
};
|
||||
// Clear existing content to make room for the input element
|
||||
tabName.textContent = '';
|
||||
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
|
||||
@@ -1804,6 +2147,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// If prefix detected, show it as non-editable label
|
||||
if (parsed) {
|
||||
const prefixLabel = document.createElement('span');
|
||||
prefixLabel.className = 'tab-rename-prefix';
|
||||
prefixLabel.textContent = parsed.prefix + ': ';
|
||||
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
|
||||
tabName.appendChild(prefixLabel);
|
||||
@@ -1816,36 +2160,67 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.className = 'tab-rename-input';
|
||||
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
||||
// should give the whole line to the input.
|
||||
const renameWidth = this.isSessionSidebarActive?.() ? '100%' : '80px';
|
||||
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
|
||||
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
||||
|
||||
tabName.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishRename = async ({ commit }) => {
|
||||
if (!this._inlineRenameActive) return; // prevent double-fire
|
||||
let editSettled = false;
|
||||
let invalidated = false;
|
||||
let completed = false;
|
||||
|
||||
const releaseRenderGuard = () => {
|
||||
if (this._activeRename !== renameHandle) return;
|
||||
this._inlineRenameActive = false;
|
||||
};
|
||||
|
||||
const completeCurrentRename = () => {
|
||||
if (this._activeRename !== renameHandle) return;
|
||||
completed = true;
|
||||
releaseRenderGuard();
|
||||
this._activeRename = null;
|
||||
this.renderSessionTabs();
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
if (invalidated || completed) return;
|
||||
invalidated = true;
|
||||
editSettled = true;
|
||||
tabName.classList.remove('tab-name-renaming');
|
||||
restoreOriginalChildren();
|
||||
completeCurrentRename();
|
||||
};
|
||||
|
||||
const finishRename = async ({ commit }) => {
|
||||
if (editSettled || invalidated) return;
|
||||
editSettled = true;
|
||||
tabName.classList.remove('tab-name-renaming');
|
||||
|
||||
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
|
||||
// so any ghost DOM is replaced with the canonical tab list, and skip the
|
||||
// API call — a cancel must not fire a stale rename PUT.
|
||||
if (!commit) {
|
||||
this.renderSessionTabs();
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._activeRename !== renameHandle) return;
|
||||
releaseRenderGuard();
|
||||
|
||||
const suffix = input.value.trim();
|
||||
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
||||
tabName.textContent = fullName || originalContent;
|
||||
if (fullName === session.name) restoreOriginalChildren();
|
||||
else tabName.textContent = fullName || originalContent;
|
||||
|
||||
// Skip the API call if the session vanished between focus and blur.
|
||||
const stillExists = this.sessions.has(sessionId);
|
||||
if (stillExists && fullName !== session.name) {
|
||||
const confirmed = await this._putSessionName(sessionId, fullName);
|
||||
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
|
||||
if (confirmed === null) {
|
||||
tabName.textContent = originalContent;
|
||||
restoreOriginalChildren();
|
||||
this.showToast('Failed to rename', 'error');
|
||||
} else {
|
||||
// The re-render below repaints from this.sessions, so the new name has
|
||||
@@ -1854,14 +2229,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
// Re-render tabs to restore full tab structure
|
||||
this.renderSessionTabs();
|
||||
completeCurrentRename();
|
||||
};
|
||||
|
||||
// Register only after the input is wired so a throw above can't strand state.
|
||||
this._activeRename = {
|
||||
const renameHandle = {
|
||||
sessionId,
|
||||
cancel: () => finishRename({ commit: false }),
|
||||
cancel: cancelRename,
|
||||
};
|
||||
this._activeRename = renameHandle;
|
||||
|
||||
input.addEventListener('blur', () => finishRename({ commit: true }));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
@@ -1873,8 +2249,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
input.blur();
|
||||
// Cancel, never commit. This used to clear the field and blur, and the
|
||||
// blur handler commits — so Escape RENAMED the session to an empty
|
||||
// string (measured: the tab fell back to its folder name and the server
|
||||
// stored ""), in every layout. cancelRename() marks the edit
|
||||
// invalidated, so the blur that follows the input's removal is a no-op.
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -3114,7 +3495,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'claude'
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
},
|
||||
|
||||
@@ -67,6 +67,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
|
||||
},
|
||||
|
||||
_onHookAgentWorking(data) {
|
||||
// The agent started a turn, so whatever it was blocked on is gone. Reported
|
||||
// by the DeepSeek status bridge; a harness turn cannot run while one of its
|
||||
// own modal approvals is on screen, so this means the dialog was answered in
|
||||
// the terminal. Same clearing as _onHookElicitationComplete, and notably NOT
|
||||
// a notification: a turn STARTING is not news.
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||
this.clearPendingHooks(data.sessionId, 'permission_prompt');
|
||||
this.clearPendingHooks(data.sessionId, 'idle_prompt');
|
||||
}
|
||||
},
|
||||
|
||||
_onHookTeammateIdle(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
|
||||
@@ -400,9 +413,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
// Same default resolution as applyTabRailWidth(): a rail that has never
|
||||
// been sized shows the width it is actually rendering at, which for
|
||||
// detailed rows is the Wide preset rather than 256. The rich-aware
|
||||
// default must come BEFORE the per-device defaults blob: the handheld
|
||||
// blob carries tabRailWidth: 256, which applyTabRailWidth() never reads,
|
||||
// so consulting it first showed a tablet's unsized rich rail as 256 while
|
||||
// it rendered at 320 — and a routine Save then PERSISTED the 256.
|
||||
width: settings.tabRailWidth ?? this._defaultTabRailWidth?.() ?? defaults.tabRailWidth ?? 256,
|
||||
}) ?? 256;
|
||||
this.syncTabRailWidthSetting?.(tabRailWidth);
|
||||
document.getElementById('appSettingsTabRailDetail').value =
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||
settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize
|
||||
);
|
||||
document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize);
|
||||
document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
||||
@@ -1197,8 +1230,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeAntigravityBtn', 'antigravity'],
|
||||
['welcomeOmpBtn', 'omp'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
['welcomePiBtn', 'pi'],
|
||||
['welcomeGrokBtn', 'grok'],
|
||||
['welcomeDeepSeekBtn', 'deepseek'],
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
['welcomeTunnelBtn', 'cloudflared'],
|
||||
@@ -2027,8 +2063,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
document.getElementById('appSettingsSessionSidebarFontSize').value
|
||||
),
|
||||
skin: document.getElementById('appSettingsSkin').value,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -2178,6 +2220,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Re-parents #sessionTabs between header host and sidebar if the layout
|
||||
// changed, then calls applyTabWrapSettings() itself — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyTabOrientation({ settleRailWidth: true });
|
||||
this.applyLineageLineSettings?.();
|
||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||
this.applyMonitorVisibility();
|
||||
@@ -2423,7 +2466,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
|
||||
webglRendererEnabled: false, // mobile always uses the DOM renderer
|
||||
@@ -2664,6 +2711,86 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
applyTabOrientation(options = {}) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const sidebarOwnsTabs = this.isSessionSidebarActive?.() === true;
|
||||
const orientation =
|
||||
!this.isSoloWindow && !sidebarOwnsTabs && window.CodemanTabOverflow?.resolveTabOrientation
|
||||
? window.CodemanTabOverflow.resolveTabOrientation({
|
||||
deviceType: MobileDetection.getDeviceType(),
|
||||
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
|
||||
})
|
||||
: 'horizontal';
|
||||
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
|
||||
root.setAttribute('data-tab-orientation', orientation);
|
||||
|
||||
// Row detail rides on its OWN attribute, exactly like the sidebar's
|
||||
// data-sidebar-detail: every html[data-tab-orientation='vertical'] rule in
|
||||
// styles.css keeps matching both variants untouched, and the gate in app.js
|
||||
// reads one attribute instead of re-parsing localStorage per tab.
|
||||
const previousDetail = root.dataset.tabRailDetail || 'rich';
|
||||
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
|
||||
root.dataset.tabRailDetail = detail;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
if (!sidebarOwnsTabs && tabsEl && rail && headerHost) {
|
||||
if (orientation === 'vertical') {
|
||||
if (tabsEl.parentElement !== rail) rail.appendChild(tabsEl);
|
||||
} else if (tabsEl.parentElement !== headerHost) {
|
||||
headerHost.appendChild(tabsEl);
|
||||
}
|
||||
}
|
||||
if (tabsEl) {
|
||||
tabsEl.setAttribute('aria-orientation', sidebarOwnsTabs || orientation === 'vertical' ? 'vertical' : 'horizontal');
|
||||
}
|
||||
|
||||
const settleRailWidth =
|
||||
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
|
||||
this.applyTabRailWidth?.({ settle: settleRailWidth });
|
||||
const orientationChanged = previous !== orientation;
|
||||
// A detail flip counts as a change on its own: simple ⟷ detailed leaves the
|
||||
// orientation on 'vertical' both times, and the stamps line is emitted by
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.fitAddon?.fit();
|
||||
}
|
||||
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
|
||||
// rail-aware, so it has to run AFTER the two attributes above — the
|
||||
// applySessionListLayout() call that precedes this one on the settings-save
|
||||
// path ran while data-tab-rail-detail still held the old value. It
|
||||
// re-renders by itself when the folder row appears or disappears, which is
|
||||
// why the render below is skipped in that case rather than doubled.
|
||||
const prevTall = this._tallTabsEnabled;
|
||||
if (changed) this.applyTabWrapSettings?.();
|
||||
if (changed) {
|
||||
// Mirror of applyTabWrapSettings()'s OWN render condition, which is
|
||||
// `prevTallTabs !== undefined && prevTallTabs !== showFolder`: its first
|
||||
// call ever only establishes the baseline and deliberately renders
|
||||
// nothing. Reading an undefined previous value as "it rendered" skips
|
||||
// BOTH renders and leaves the rows stale — reachable whenever this is the
|
||||
// first call, i.e. when the pre-paint script threw and left the
|
||||
// attributes on their fallbacks for applyTabOrientation() to correct.
|
||||
const wrapRendered = prevTall !== undefined && prevTall !== this._tallTabsEnabled;
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
// on paths where nothing re-rendered (boot with the layout already applied).
|
||||
if (this.isRichTabRows?.()) this._startSidebarRichClock?.();
|
||||
else this._stopSidebarRichClock?.();
|
||||
},
|
||||
|
||||
applyTabWrapSettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -2683,7 +2810,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const twoRows = !sidebar && deviceType === 'desktop'
|
||||
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
|
||||
: false;
|
||||
const showFolder = sidebar || twoRows;
|
||||
// The DETAILED vertical rail is the third tall-row surface, for the same
|
||||
// reason as the sidebar: it is a docked column with a row per session, and
|
||||
// the stamps line below the name says nothing about WHICH project the
|
||||
// session is in. Read from the applied attribute, which applyTabOrientation()
|
||||
// has already written (app.js calls it before this).
|
||||
const railRich = this.isTabRailRich?.() === true;
|
||||
const showFolder = sidebar || twoRows || railRich;
|
||||
const prevTallTabs = this._tallTabsEnabled;
|
||||
this._tallTabsEnabled = showFolder;
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
@@ -2769,7 +2902,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (fileBrowserPanel.classList.contains('visible')) {
|
||||
this._resetFileBrowserForHide?.();
|
||||
fileBrowserPanel.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
@@ -2897,7 +3031,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily',
|
||||
'language',
|
||||
|
||||
+540
-25
@@ -62,6 +62,7 @@
|
||||
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
|
||||
--header-height: 36px;
|
||||
--toolbar-height: 42px;
|
||||
--tab-rail-width: 256px;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */
|
||||
--sidebar-width-collapsed: 44px; /* == --touch-target-min */
|
||||
@@ -347,7 +348,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.history-view-all-btn,
|
||||
.session-tab .tab-mode.gemini,
|
||||
.session-tab .tab-mode.antigravity,
|
||||
.session-tab .tab-mode.pi
|
||||
.session-tab .tab-mode.pi,
|
||||
.session-tab .tab-mode.grok,
|
||||
.session-tab .tab-mode.omp
|
||||
) {
|
||||
color: var(--accent-d);
|
||||
}
|
||||
@@ -574,6 +577,99 @@ body {
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
.tab-rail {
|
||||
display: none;
|
||||
flex: 0 0 var(--tab-rail-width);
|
||||
width: var(--tab-rail-width);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--glass-bg);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs {
|
||||
--lineage-vertical-gutter: 24px;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
padding: 0.35rem;
|
||||
padding-left: calc(0.35rem + var(--lineage-vertical-gutter));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .header-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tab-rail-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-rail-resize-handle:hover,
|
||||
.tab-rail-resize-handle:focus-visible {
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
html:not([data-tab-orientation='vertical']) .tab-rail-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-rail-resize-shield:not([hidden]) {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
body.tab-rail-resizing,
|
||||
body.tab-rail-resizing * {
|
||||
cursor: ew-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tab-rail,
|
||||
.tab-rail-resize-handle {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.session-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1421,6 +1517,53 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-tab .tab-name-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
:is(
|
||||
html[data-tab-orientation='vertical'] .tab-rail,
|
||||
html[data-session-list='sidebar'] .session-sidebar
|
||||
) .session-tab .tab-name.tab-name-renaming {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
-webkit-box-orient: initial;
|
||||
-webkit-line-clamp: unset;
|
||||
line-clamp: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:is(
|
||||
html[data-tab-orientation='vertical'] .tab-rail,
|
||||
html[data-session-list='sidebar'] .session-sidebar
|
||||
) .tab-name-renaming .tab-rename-prefix {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
:is(
|
||||
html[data-tab-orientation='vertical'] .tab-rail,
|
||||
html[data-session-list='sidebar'] .session-sidebar
|
||||
) .tab-name-renaming .tab-rename-input {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||
.session-tab .tab-folder {
|
||||
font-size: 0.6rem;
|
||||
@@ -2152,6 +2295,123 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-tab .tab-more {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: 700 1rem/1 monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar .tab-name-row > .tab-actions > .tab-more,
|
||||
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail .tab-name-row > .tab-actions > .tab-more,
|
||||
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab.active > .tab-actions > .tab-more {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.session-tab:hover > .tab-actions > .tab-more,
|
||||
.session-tab:focus-within > .tab-actions > .tab-more,
|
||||
.session-tab.active > .tab-actions > .tab-more,
|
||||
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar
|
||||
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
|
||||
.tab-name-row
|
||||
> .tab-actions
|
||||
> .tab-more,
|
||||
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
|
||||
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
|
||||
.tab-name-row
|
||||
> .tab-actions
|
||||
> .tab-more {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.session-tab > .tab-actions > .tab-more,
|
||||
html[data-session-list='sidebar'][data-sidebar='expanded']
|
||||
.session-sidebar
|
||||
.session-tab
|
||||
.tab-name-row
|
||||
> .tab-actions
|
||||
> .tab-more,
|
||||
html[data-tab-orientation='vertical']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab
|
||||
.tab-name-row
|
||||
> .tab-actions
|
||||
> .tab-more {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-rail-action-menu {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
display: grid;
|
||||
min-width: 180px;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--btn-radius);
|
||||
background: var(--floating-bg);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
}
|
||||
|
||||
.tab-rail-action-menu button {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-rail-action-menu button:hover,
|
||||
.tab-rail-action-menu button:focus-visible {
|
||||
background: var(--control-bg-hover);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-rail-action-menu .danger {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(112px, 1fr) 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(220px, 44vw);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field input {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field output {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
||||
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
||||
A tab that is ALREADY detached keeps its icon regardless: it is the
|
||||
@@ -2240,12 +2500,31 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
color: #22d3ee;
|
||||
}
|
||||
.session-tab .tab-mode.omp {
|
||||
background: rgba(129, 140, 248, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.pi {
|
||||
background: rgba(244, 114, 182, 0.2);
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.grok {
|
||||
background: rgba(212, 212, 216, 0.18);
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the
|
||||
light-skin `--accent-d` override list above (which rescues gemini/antigravity/
|
||||
pi/grok, whose pastels wash out on paper backgrounds) — this indigo already
|
||||
carries enough contrast on the light skins, and overriding it would throw away
|
||||
the one cue that separates a dsh tab from its neighbours. */
|
||||
.session-tab .tab-mode.deepseek {
|
||||
background: rgba(77, 107, 254, 0.18);
|
||||
color: #7c93ff;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
@@ -3609,6 +3888,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #fff1f7;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and
|
||||
.run-mode-dot.omp so the welcome action reads as the same backend. */
|
||||
.welcome-btn-omp {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
color: #e0e7ff;
|
||||
box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-omp:hover {
|
||||
background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%);
|
||||
box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(165, 180, 252, 0.5);
|
||||
color: #eef2ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok
|
||||
and .run-mode-dot.grok so the welcome action reads as the same backend. */
|
||||
.welcome-btn-grok {
|
||||
background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%);
|
||||
border-color: rgba(212, 212, 216, 0.4);
|
||||
color: #f4f4f5;
|
||||
box-shadow: 0 2px 8px rgba(212, 212, 216, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-grok:hover {
|
||||
background: linear-gradient(135deg, #1f1f23 0%, #3f3f46 55%, #52525b 100%);
|
||||
box-shadow: 0 4px 20px rgba(212, 212, 216, 0.22), 0 0 40px rgba(161, 161, 170, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(228, 228, 231, 0.5);
|
||||
color: #fafafa;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* DeepSeek Harness: the #4d6bfe blue identity, matching
|
||||
.btn-toolbar.btn-run.mode-deepseek and .run-mode-dot.deepseek so the welcome
|
||||
action reads as the same backend. */
|
||||
.welcome-btn-deepseek {
|
||||
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
|
||||
border-color: rgba(124, 147, 255, 0.4);
|
||||
color: #eef2ff;
|
||||
box-shadow: 0 2px 8px rgba(77, 107, 254, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-deepseek:hover {
|
||||
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
|
||||
box-shadow: 0 4px 20px rgba(77, 107, 254, 0.3), 0 0 40px rgba(39, 64, 196, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(150, 170, 255, 0.5);
|
||||
color: #f8faff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.welcome-btn-gemini {
|
||||
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
|
||||
@@ -4683,6 +5013,59 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
border-color: rgba(249, 168, 212, 0.6);
|
||||
color: #fff1f7;
|
||||
}
|
||||
/* OMP mode colors */
|
||||
.btn-toolbar.btn-run.mode-omp,
|
||||
.btn-toolbar.btn-run-gear.mode-omp {
|
||||
background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
color: #e0e7ff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-omp:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-omp:hover {
|
||||
background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%);
|
||||
box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(165, 180, 252, 0.6);
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
||||
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
|
||||
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-grok`). */
|
||||
.btn-toolbar.btn-run.mode-grok,
|
||||
.btn-toolbar.btn-run-gear.mode-grok {
|
||||
background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%);
|
||||
border-color: rgba(212, 212, 216, 0.5);
|
||||
color: #f4f4f5;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-grok:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-grok:hover {
|
||||
background: linear-gradient(135deg, #1f1f23 0%, #3f3f46 55%, #52525b 100%);
|
||||
box-shadow: 0 0 12px rgba(212, 212, 216, 0.28), 0 2px 8px rgba(63, 63, 70, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(228, 228, 231, 0.6);
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
/* DeepSeek mode colors. Same cascade note as pi/grok above: this base-sheet pair
|
||||
only renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so deepseek also
|
||||
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-deepseek`). */
|
||||
.btn-toolbar.btn-run.mode-deepseek,
|
||||
.btn-toolbar.btn-run-gear.mode-deepseek {
|
||||
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
|
||||
border-color: rgba(124, 147, 255, 0.55);
|
||||
color: #eef2ff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-deepseek:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-deepseek:hover {
|
||||
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
|
||||
box-shadow: 0 0 12px rgba(77, 107, 254, 0.35), 0 2px 8px rgba(39, 64, 196, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(150, 170, 255, 0.65);
|
||||
color: #f8faff;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.run-mode-menu {
|
||||
@@ -4767,6 +5150,9 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
.run-mode-dot.gemini { background: #8ab4f8; }
|
||||
.run-mode-dot.antigravity { background: #22d3ee; }
|
||||
.run-mode-dot.pi { background: #f472b6; }
|
||||
.run-mode-dot.grok { background: #a1a1aa; }
|
||||
.run-mode-dot.deepseek { background: #4d6bfe; }
|
||||
.run-mode-dot.omp { background: #818cf8; }
|
||||
.run-mode-dot.shell { background: #94a3b8; }
|
||||
|
||||
/* Phone-only Enter button (see index.html). Hidden by default at every width;
|
||||
@@ -12023,17 +12409,20 @@ kbd {
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
|
||||
live Claude and Codex plan limits in provider rows. Ships
|
||||
hidden via the marker class below because display is PER-DEVICE and the
|
||||
server cannot read localStorage; settings-ui.js reveals it on load (desktop
|
||||
default ON, handhelds OFF) and on a live toggle. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 11px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-height: 22px;
|
||||
padding: 3px 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--text-dim);
|
||||
@@ -12042,6 +12431,23 @@ kbd {
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.header-plan-usage .pu-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
.header-plan-usage .pu-provider {
|
||||
width: 46px;
|
||||
flex: 0 0 46px;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.header-plan-usage .pu-windows {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
|
||||
.header-plan-usage .pu-win {
|
||||
display: inline-flex;
|
||||
@@ -14102,6 +14508,25 @@ html:not([data-skin="og"]) {
|
||||
color: #fff1f7;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-pi:hover { box-shadow: 0 0 14px -2px rgba(244, 114, 182, 0.45); }
|
||||
/* Grok keeps its charcoal identity on the non-og skins. Same specificity trap
|
||||
as pi above: without this rule the generic `.btn-toolbar.btn-run` in this
|
||||
nested block wins and grok renders as generic claude blue. */
|
||||
.btn-toolbar.btn-run.mode-grok {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
color: #fafafa;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
|
||||
/* DeepSeek keeps its indigo on the non-og skins — same specificity trap as pi
|
||||
and grok above: without this rule the generic `.btn-toolbar.btn-run` in this
|
||||
nested block wins and deepseek renders as generic claude blue, which is the
|
||||
one colour it must not be mistaken for. */
|
||||
.btn-toolbar.btn-run.mode-deepseek {
|
||||
background: linear-gradient(135deg, #2740c4, #4d6bfe);
|
||||
border-color: #1b2a8f;
|
||||
color: #f8faff;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
|
||||
.btn-toolbar.btn-run-gear {
|
||||
background: var(--accent-d);
|
||||
border-color: var(--accent);
|
||||
@@ -14754,6 +15179,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .home-sessions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the
|
||||
matchMedia listener must never leave the column overlapping the content. */
|
||||
@media (max-width: 1179px) {
|
||||
@@ -16530,6 +16959,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
padding: 0 22px 22px;
|
||||
}
|
||||
|
||||
#sessionOptionsModal #context-tab {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.25rem 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#sessionOptionsModal .set-section {
|
||||
padding-top: 18px;
|
||||
}
|
||||
@@ -16547,6 +16983,22 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
#sessionOptionsModal .modal-content.modal-lg {
|
||||
width: min(1120px, 96vw);
|
||||
max-width: min(1120px, 96vw);
|
||||
}
|
||||
|
||||
#sessionOptionsModal #context-tab {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#sessionOptionsModal #context-tab > .set-section-head,
|
||||
#sessionOptionsModal #context-tab > .set-section-blurb {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
#sessionOptionsModal .set-group + .set-group {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -16836,10 +17288,27 @@ html[data-session-list="sidebar"] .session-sidebar .tab-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .tab-name-row,
|
||||
html[data-session-list="sidebar"] .session-sidebar .tab-name {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
html[data-session-list='sidebar'] .session-sidebar .tab-name {
|
||||
flex: 0 1 auto;
|
||||
white-space: nowrap;
|
||||
font-size: var(--session-sidebar-name-font-size, 12px);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
|
||||
font-size: var(--session-sidebar-name-font-size, 12px);
|
||||
}
|
||||
|
||||
html[data-session-list='sidebar'] .session-sidebar .tab-actions,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .tab-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row
|
||||
actions permanently visible on the active session — no layout jitter when
|
||||
the pointer crosses the list. */
|
||||
@@ -16853,11 +17322,13 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-clos
|
||||
/* Drag-reorder indicators become horizontal edges. The class names stay
|
||||
drag-over-left / drag-over-right (they read as before/after now) so app.js,
|
||||
the base rules above and the generated gesture bundle need no renaming. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left {
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-left {
|
||||
box-shadow: 0 -2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right {
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right {
|
||||
box-shadow: 0 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
@@ -16870,19 +17341,31 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- Rich rows (sessionListLayout 'sidebar-rich') ----------------------- */
|
||||
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
|
||||
/* The detailed variant of the SAME sidebar: identical column, identical
|
||||
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
|
||||
difference is that each row also carries the line the desktop home rail and
|
||||
the phone overview carry — when the session was first created, how long it
|
||||
has been in the state it is in, and a status pill.
|
||||
|
||||
Everything here is scoped to html[data-sidebar-detail="rich"], which
|
||||
The VERTICAL TAB RAIL is the second surface that draws those rows (it is a
|
||||
docked column too, and #sessionTabs is the same element re-parented into it),
|
||||
so every rule below carries a rail twin as an extra COMMA-GROUPED selector.
|
||||
Deliberately not :is(): an :is() list takes its most specific argument's
|
||||
specificity, which would silently raise the sidebar arm from (0,3,1) to the
|
||||
rail arm's (0,5,1) and let these paint rules outrank things they never used
|
||||
to. Grouped selectors each keep their own weight.
|
||||
|
||||
Sidebar rules are scoped to html[data-sidebar-detail="rich"], which
|
||||
applySessionListLayout() only ever sets to 'rich' while data-session-list is
|
||||
'sidebar'. `.tab-meta` is emitted by the row template exclusively in that
|
||||
mode, so these rules have nothing to match anywhere else — the display:none
|
||||
below is the second lock, not the mechanism. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
|
||||
'sidebar'; rail rules to html[data-tab-orientation='vertical']
|
||||
[data-tab-rail-detail='rich']:not(.tab-rail-compact), so a rail dragged below
|
||||
240px drops back to simple rows the same way the collapsed sidebar does.
|
||||
`.tab-meta` is emitted by the row template exclusively in those modes, so
|
||||
these rules have nothing to match anywhere else — the display:none below is
|
||||
the second lock, not the mechanism. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
@@ -16904,33 +17387,56 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-item {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-key {
|
||||
margin-right: 0.35em;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Narrower than the detailed default (the .tab-rail-tight class,
|
||||
_setTabRailWidth): the created stamp is dropped rather than shown as
|
||||
"CREA…". Its value stays reachable as the tooltip on the meta LINE
|
||||
(`.tab-meta` carries both absolute stamps, _sidebarRichMetaHTML) — the title
|
||||
on the hidden `.tab-meta-created` itself goes away with it, since a
|
||||
`display: none` element has no hover target. */
|
||||
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-created,
|
||||
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* On a rail narrower than the detailed default, the state duration is the last
|
||||
thing that should go: it is the number the row is sorted by, and the pill
|
||||
next to it is only a word. The created stamp ellipsizes instead. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-since {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* While a session is actually doing something, how long it has been doing it is
|
||||
what the eye should land on — same emphasis the home rail gives it. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working .tab-meta-since {
|
||||
color: var(--green);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* Pushed hard right and never shrinking, so the stamps ellipsize before the
|
||||
status word does. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 0.1em 0.5em;
|
||||
@@ -16947,19 +17453,23 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
|
||||
/* Same three colors as every other session surface: red means a question is
|
||||
pending, yellow means it wants input, green means work is happening. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--needs,
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--needs,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--error {
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--red) 45%, transparent);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--waiting {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--working {
|
||||
background: color-mix(in srgb, var(--green) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
||||
color: var(--green);
|
||||
@@ -16967,7 +17477,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
|
||||
|
||||
/* Muted one step further than the idle dot: the pill is a block of color, so it
|
||||
reads louder than a 9px dot at the same mix. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--idle {
|
||||
background: color-mix(in srgb, var(--green) 7%, transparent);
|
||||
border-color: color-mix(in srgb, var(--green) 18%, var(--border));
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
@@ -16982,7 +17493,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
|
||||
then just a status dot and its badges. Without the guard, `align-items:
|
||||
flex-start` and a 0.15rem top margin on .tab-status would push that dot off
|
||||
the centre line of every row in the rail. */
|
||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab {
|
||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||
align-items: flex-start;
|
||||
padding: 0.45rem 0.5rem;
|
||||
}
|
||||
@@ -16991,7 +17503,10 @@ html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sideba
|
||||
three-line one it drifts low, so pin it to the name it acts on. */
|
||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-actions,
|
||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-number,
|
||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status {
|
||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name-row > .tab-actions,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-number,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @fileoverview Accessible, device-local vertical session-rail sizing.
|
||||
*
|
||||
* Pointer + keyboard resizing for the vertical tab rail (`tabOrientation:
|
||||
* 'vertical'`), with a preferred width persisted per device (`tabRailWidth`)
|
||||
* and re-clamped against the viewport on resize. During a drag it takes
|
||||
* ownership of terminal refits (`_tabRailResizeOwnsObserver` suppresses
|
||||
* terminal-ui's throttled ResizeObserver) and performs ONE settle-time resize,
|
||||
* reverting per-frame below 40 columns so the PTY is never thrashed.
|
||||
*
|
||||
* @dependency CodemanApp (app.js) - methods attach to its prototype
|
||||
* @dependency CodemanTabRail (constants.js) - width policy (resolveWidth, bounds)
|
||||
* @loadorder 6.5 (after app.js, before terminal-ui.js)
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_getTabRailMinimumTerminalWidth() {
|
||||
const cellWidth = this.terminal?._core?._renderService?.dimensions?.css?.cell?.width;
|
||||
if (Number.isFinite(cellWidth) && cellWidth > 0) return Math.ceil(cellWidth * 40 + 24);
|
||||
return 420;
|
||||
},
|
||||
|
||||
_getTabRailBounds() {
|
||||
const main = document.querySelector('.main');
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
mainWidth: main?.clientWidth || window.innerWidth,
|
||||
minTerminalWidth: this._getTabRailMinimumTerminalWidth(),
|
||||
};
|
||||
},
|
||||
|
||||
_getCurrentTabRailWidth() {
|
||||
const fromCss = Number.parseFloat(document.documentElement.style.getPropertyValue('--tab-rail-width'));
|
||||
if (Number.isFinite(fromCss)) return fromCss;
|
||||
const measured = document.getElementById('tabRail')?.getBoundingClientRect?.().width;
|
||||
return Number.isFinite(measured) && measured > 0 ? measured : window.CodemanTabRail?.DEFAULT_WIDTH || 256;
|
||||
},
|
||||
|
||||
readTabRailWidthSetting() {
|
||||
const select = document.getElementById('appSettingsTabRailWidth');
|
||||
if (!select) return this._getCurrentTabRailWidth();
|
||||
if (select.value === 'custom') return Number(select.dataset.currentWidth) || this._getCurrentTabRailWidth();
|
||||
return Number(select.value) || window.CodemanTabRail?.DEFAULT_WIDTH || 256;
|
||||
},
|
||||
|
||||
syncTabRailWidthSetting(width) {
|
||||
const select = document.getElementById('appSettingsTabRailWidth');
|
||||
if (!select) return;
|
||||
const rounded = Math.round(width);
|
||||
select.dataset.currentWidth = String(rounded);
|
||||
const preset = select.querySelector(`option[value="${rounded}"]`);
|
||||
if (preset) {
|
||||
select.value = String(rounded);
|
||||
return;
|
||||
}
|
||||
const custom = select.querySelector('option[value="custom"]');
|
||||
if (custom) custom.textContent = `Custom (${rounded}px)`;
|
||||
select.value = 'custom';
|
||||
},
|
||||
|
||||
_setTabRailWidth(width) {
|
||||
const policy = window.CodemanTabRail;
|
||||
if (!policy) return 256;
|
||||
const preferred = policy.resolveWidth({ width });
|
||||
const bounds = this._getTabRailBounds();
|
||||
const resolved = policy.resolveWidth({ width: preferred, ...bounds });
|
||||
const effectiveMax = policy.resolveWidth({ width: policy.MAX_WIDTH, ...bounds });
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--tab-rail-width', `${resolved}px`);
|
||||
const wasCompact = root.classList.contains('tab-rail-compact');
|
||||
const compact = resolved < 240;
|
||||
root.classList.toggle('tab-rail-compact', compact);
|
||||
// Second, softer threshold, CSS-only: a detailed row carries two stamps and
|
||||
// below ~288px the created one ellipsizes to "CREA…", which says nothing.
|
||||
// It is dropped there instead, leaving the state duration (the number the
|
||||
// list is ordered by) and its pill intact. No re-render — unlike the rows
|
||||
// themselves, this is a display toggle on markup that is already there.
|
||||
root.classList.toggle('tab-rail-tight', resolved < 288);
|
||||
if (wasCompact !== compact) {
|
||||
// The folder line is owned by applyTabWrapSettings(), whose railRich
|
||||
// input reads the compact class this function just toggled — without
|
||||
// re-running it, a rich rail dragged below 240px kept emitting folder
|
||||
// rows (and, for a stored width < 240, kept them across reloads: the
|
||||
// boot-time wrap pass runs before this function first applies the
|
||||
// class). It re-renders only when the folder flag actually flipped, so
|
||||
// cover the flip-without-folder-change case (a simple-detail rail
|
||||
// crossing 240px still changes the row-action affordance) without
|
||||
// rendering twice.
|
||||
const prevTall = this._tallTabsEnabled;
|
||||
this.applyTabWrapSettings?.();
|
||||
const wrapRendered = prevTall !== undefined && this._tallTabsEnabled !== prevTall;
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
}
|
||||
const handle = document.getElementById('tabRailResizeHandle');
|
||||
if (handle) {
|
||||
handle.setAttribute('aria-valuemax', String(effectiveMax));
|
||||
handle.setAttribute('aria-valuenow', String(resolved));
|
||||
}
|
||||
this.syncTabRailWidthSetting(preferred);
|
||||
return resolved;
|
||||
},
|
||||
|
||||
_persistTabRailWidth(width) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
if (settings.tabRailWidth === width) return;
|
||||
settings.tabRailWidth = width;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
},
|
||||
|
||||
_claimTabRailResize() {
|
||||
this._tabRailResizeOwnsObserver = true;
|
||||
clearTimeout(this._tabRailResizeWatchdog);
|
||||
clearTimeout(this._tabRailReleaseTimer);
|
||||
if (this._tabRailReleaseRaf) cancelAnimationFrame(this._tabRailReleaseRaf);
|
||||
this._armTabRailResizeWatchdog();
|
||||
if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf);
|
||||
if (this._resizeTimeout) clearTimeout(this._resizeTimeout);
|
||||
this._resizeRaf = null;
|
||||
this._resizeTimeout = null;
|
||||
},
|
||||
|
||||
_armTabRailResizeWatchdog() {
|
||||
this._tabRailResizeWatchdog = setTimeout(() => {
|
||||
this._tabRailResizeWatchdog = null;
|
||||
if (document.body.classList.contains('tab-rail-resizing')) {
|
||||
this._armTabRailResizeWatchdog();
|
||||
return;
|
||||
}
|
||||
this._tabRailResizeOwnsObserver = false;
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
_releaseTabRailResize() {
|
||||
clearTimeout(this._tabRailResizeWatchdog);
|
||||
this._tabRailResizeWatchdog = null;
|
||||
const release = () => {
|
||||
clearTimeout(this._tabRailReleaseTimer);
|
||||
this._tabRailReleaseTimer = null;
|
||||
this._tabRailReleaseRaf = null;
|
||||
this._tabRailResizeOwnsObserver = false;
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
this._tabRailReleaseRaf = requestAnimationFrame(() => {
|
||||
this._tabRailReleaseRaf = requestAnimationFrame(release);
|
||||
});
|
||||
this._tabRailReleaseTimer = setTimeout(release, 250);
|
||||
} else release();
|
||||
},
|
||||
|
||||
_scheduleTabRailSettle(effective, preferred = effective) {
|
||||
clearTimeout(this._tabRailSettleTimer);
|
||||
this._tabRailSettleTimer = setTimeout(async () => {
|
||||
this._tabRailSettleTimer = null;
|
||||
this._persistTabRailWidth(preferred);
|
||||
try {
|
||||
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
|
||||
else this.fitAddon?.fit();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
} catch (error) {
|
||||
console.warn('Failed to resize terminal after rail resize:', error);
|
||||
} finally {
|
||||
this._releaseTabRailResize();
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* The width a rail gets when the user has never picked one.
|
||||
*
|
||||
* Detailed rows carry a third line ("created 3d ago · working 12m" plus a
|
||||
* status pill) and at 256px that line ellipsizes before it is finished — the
|
||||
* same reason the rich SIDEBAR is 300px and the simple one 260px. 320px is
|
||||
* the existing Wide preset, so a fresh detailed rail lands on a named choice
|
||||
* rather than reading "Custom" in the settings select.
|
||||
*
|
||||
* Only the DEFAULT moves: a width the user has actually chosen (stored) is
|
||||
* never overridden, and dragging the rail narrower is never fought — below
|
||||
* 240px the rows drop back to simple ones on their own.
|
||||
*/
|
||||
_defaultTabRailWidth() {
|
||||
const rich = document.documentElement.dataset.tabRailDetail !== 'simple';
|
||||
if (rich) return window.CodemanTabRail?.RICH_DEFAULT_WIDTH ?? 320;
|
||||
return window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
|
||||
},
|
||||
|
||||
applyTabRailWidth(options = {}) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const requested = settings.tabRailWidth ?? this._defaultTabRailWidth();
|
||||
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
|
||||
if (options.settle) this._claimTabRailResize();
|
||||
const resolved = this._setTabRailWidth(preferred);
|
||||
if (options.persist !== false && requested !== preferred) this._persistTabRailWidth(preferred);
|
||||
if (options.settle) this._scheduleTabRailSettle(resolved, preferred);
|
||||
return resolved;
|
||||
},
|
||||
|
||||
_applyTabRailPointerWidth(clientX) {
|
||||
const main = document.querySelector('.main');
|
||||
if (!main) return this._getCurrentTabRailWidth();
|
||||
const previous = this._getCurrentTabRailWidth();
|
||||
const width = this._setTabRailWidth(clientX - main.getBoundingClientRect().left);
|
||||
let proposed = null;
|
||||
try {
|
||||
proposed = this.fitAddon?.proposeDimensions?.();
|
||||
} catch {}
|
||||
return proposed && proposed.cols < 40 ? this._setTabRailWidth(previous) : width;
|
||||
},
|
||||
|
||||
_queueTabRailPointerWidth(clientX) {
|
||||
this._tabRailPendingClientX = clientX;
|
||||
if (this._tabRailPointerRaf) return;
|
||||
this._tabRailPointerRaf = requestAnimationFrame(() => {
|
||||
this._tabRailPointerRaf = null;
|
||||
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
|
||||
});
|
||||
},
|
||||
|
||||
_finishTabRailDrag(handle, pointerId) {
|
||||
if (!document.body.classList.contains('tab-rail-resizing')) return;
|
||||
if (this._tabRailPointerRaf) {
|
||||
cancelAnimationFrame(this._tabRailPointerRaf);
|
||||
this._tabRailPointerRaf = null;
|
||||
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
|
||||
}
|
||||
document.body.classList.remove('tab-rail-resizing');
|
||||
const shield = document.getElementById('tabRailResizeShield');
|
||||
if (shield) shield.hidden = true;
|
||||
try {
|
||||
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
|
||||
} catch {}
|
||||
const preferred = window.CodemanTabRail?.resolveWidth({ width: this._tabRailDragWidth }) ?? this._tabRailDragWidth;
|
||||
this._scheduleTabRailSettle(this._tabRailDragWidth || this._getCurrentTabRailWidth(), preferred);
|
||||
},
|
||||
|
||||
_onTabRailKeyDown(event) {
|
||||
const width = window.CodemanTabRail?.resolveKeyboardWidth({
|
||||
key: event.key,
|
||||
shiftKey: event.shiftKey,
|
||||
currentWidth: this._getCurrentTabRailWidth(),
|
||||
defaultWidth: this._defaultTabRailWidth?.(),
|
||||
...this._getTabRailBounds(),
|
||||
});
|
||||
if (width === null || width === undefined) return;
|
||||
event.preventDefault();
|
||||
this._claimTabRailResize();
|
||||
const effective = this._setTabRailWidth(width);
|
||||
this._scheduleTabRailSettle(effective, window.CodemanTabRail.resolveWidth({ width }));
|
||||
},
|
||||
|
||||
initTabRailResize() {
|
||||
const handle = document.getElementById('tabRailResizeHandle');
|
||||
if (!handle || handle.dataset.ready === '1') return;
|
||||
handle.dataset.ready = '1';
|
||||
this.applyTabRailWidth();
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
this.closeTabRailActionMenu();
|
||||
this._claimTabRailResize();
|
||||
this._tabRailDragWidth = this._getCurrentTabRailWidth();
|
||||
this._tabRailPendingClientX = event.clientX;
|
||||
document.body.classList.add('tab-rail-resizing');
|
||||
const shield = document.getElementById('tabRailResizeShield');
|
||||
if (shield) shield.hidden = false;
|
||||
try {
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
document.body.classList.remove('tab-rail-resizing');
|
||||
if (shield) shield.hidden = true;
|
||||
this._releaseTabRailResize();
|
||||
}
|
||||
});
|
||||
handle.addEventListener('pointermove', (event) => {
|
||||
if (handle.hasPointerCapture?.(event.pointerId)) this._queueTabRailPointerWidth(event.clientX);
|
||||
});
|
||||
handle.addEventListener('pointerup', (event) => this._finishTabRailDrag(handle, event.pointerId));
|
||||
handle.addEventListener('pointercancel', (event) => this._finishTabRailDrag(handle, event.pointerId));
|
||||
handle.addEventListener('lostpointercapture', (event) => this._finishTabRailDrag(handle, event.pointerId));
|
||||
handle.addEventListener('keydown', (event) => this._onTabRailKeyDown(event));
|
||||
handle.addEventListener('dblclick', (event) => {
|
||||
event.preventDefault();
|
||||
this._claimTabRailResize();
|
||||
// Rich-aware: resetting a detailed rail to 256 would land it below the
|
||||
// 288px tight threshold and silently drop the created stamp.
|
||||
const preferred = this._defaultTabRailWidth?.() ?? (window.CodemanTabRail?.DEFAULT_WIDTH || 256);
|
||||
const effective = this._setTabRailWidth(preferred);
|
||||
this._scheduleTabRailSettle(effective, preferred);
|
||||
});
|
||||
|
||||
let resizeTimer = null;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
this.applyTabOrientation?.();
|
||||
this.applyTabRailWidth({ persist: false });
|
||||
}, 100);
|
||||
});
|
||||
},
|
||||
|
||||
closeTabRailActionMenu(options = {}) {
|
||||
const menu = document.querySelector('.tab-rail-action-menu');
|
||||
const trigger = this._tabRailActionMenuTrigger;
|
||||
menu?.remove();
|
||||
if (this._tabRailActionMenuOutside) {
|
||||
document.removeEventListener('pointerdown', this._tabRailActionMenuOutside, true);
|
||||
this._tabRailActionMenuOutside = null;
|
||||
}
|
||||
if (this._tabRailActionMenuViewport) {
|
||||
window.removeEventListener('resize', this._tabRailActionMenuViewport);
|
||||
this._tabRailActionMenuViewport = null;
|
||||
}
|
||||
this._tabRailActionMenuTrigger = null;
|
||||
if (options.restoreFocus) trigger?.focus?.();
|
||||
},
|
||||
|
||||
openTabRailActionMenu(event, sessionId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.closeTabRailActionMenu();
|
||||
const trigger = event.currentTarget;
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'tab-rail-action-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', 'Session actions');
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||
];
|
||||
for (const action of actions) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.textContent = action.label;
|
||||
if (action.className) button.className = action.className;
|
||||
button.addEventListener('click', () => {
|
||||
this.closeTabRailActionMenu();
|
||||
action.run();
|
||||
});
|
||||
menu.appendChild(button);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(8, Math.min(rect.right - menuRect.width, window.innerWidth - menuRect.width - 8))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
|
||||
this._tabRailActionMenuTrigger = trigger;
|
||||
this._tabRailActionMenuOutside = (pointerEvent) => {
|
||||
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== trigger) this.closeTabRailActionMenu();
|
||||
};
|
||||
document.addEventListener('pointerdown', this._tabRailActionMenuOutside, true);
|
||||
this._tabRailActionMenuViewport = () => this.closeTabRailActionMenu();
|
||||
window.addEventListener('resize', this._tabRailActionMenuViewport, { once: true });
|
||||
menu.addEventListener('keydown', (keyEvent) => {
|
||||
const buttons = [...menu.querySelectorAll('button')];
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
if (keyEvent.key === 'Escape') {
|
||||
keyEvent.preventDefault();
|
||||
this.closeTabRailActionMenu({ restoreFocus: true });
|
||||
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
|
||||
keyEvent.preventDefault();
|
||||
const direction = keyEvent.key === 'ArrowDown' ? 1 : -1;
|
||||
buttons[(index + direction + buttons.length) % buttons.length]?.focus();
|
||||
}
|
||||
});
|
||||
menu.querySelector('button')?.focus();
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user