mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00229ee29 |
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.35.0",
|
||||
"version": "1.33.3",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.35.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6f88e40: ### Thanks
|
||||
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
|
||||
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
|
||||
|
||||
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
|
||||
|
||||
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
|
||||
|
||||
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
|
||||
|
||||
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
|
||||
|
||||
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
|
||||
|
||||
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
|
||||
|
||||
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
|
||||
|
||||
## 1.34.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 1.33.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -444,11 +444,8 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **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
|
||||
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
|
||||
- **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)
|
||||
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
||||
- **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
|
||||
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/tab-layout-editing.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -33,15 +31,8 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -445,20 +445,6 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
|
||||
slot, because the routes release the waiter when the client disconnects, but a
|
||||
client that opens many concurrent waits against one session will still hit the cap.
|
||||
|
||||
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
|
||||
|
||||
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||
geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
`Server-Timing` header reports `capture`, `prepare` and `total`.
|
||||
|
||||
| Query | Meaning |
|
||||
|---|---|
|
||||
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
|
||||
## Session lineage (`parentSessionId`)
|
||||
|
||||
A create request may name the session that spawned it, which the web UI draws as a
|
||||
@@ -484,32 +470,6 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||
unresolved) and survives a server restart.
|
||||
|
||||
## Session model (`displayModel`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||
session runs as far as the server knows it, for the web UI's session headers:
|
||||
|
||||
```json
|
||||
"displayModel": { "model": "qwen3.8-27b", "source": "screen" }
|
||||
```
|
||||
|
||||
`source` is where it came from, strongest first:
|
||||
|
||||
| `source` | Meaning |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
|
||||
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
|
||||
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
|
||||
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
|
||||
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
|
||||
|
||||
Between `statusline` and `screen` the newest report wins. The field is absent when no
|
||||
model is known (a shell, a CLI that reports none and was launched without one). `model`
|
||||
is display text from a pane or a CLI report: control characters are stripped and it is at
|
||||
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
|
||||
persisted and restored after a server restart until the next report replaces it; a
|
||||
`config` value is read again at every pane start, attach and relaunch instead.
|
||||
|
||||
## Approvals Inbox
|
||||
|
||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||
@@ -740,44 +700,6 @@ normal `caseName`/`mode`/etc. body)
|
||||
jarring than a full relaunch, and folding it into the one-shot path is
|
||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||
|
||||
## Creating a case in a custom folder
|
||||
|
||||
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
|
||||
|
||||
The target is judged before anything is written:
|
||||
|
||||
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
|
||||
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
|
||||
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
|
||||
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
|
||||
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
|
||||
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
|
||||
|
||||
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
|
||||
|
||||
## Git status
|
||||
|
||||
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||
|
||||
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
|
||||
|
||||
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
||||
|
||||
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
||||
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
||||
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
||||
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||
|
||||
`data` is `{ state, repos, reposTruncated, checkedAt }`:
|
||||
|
||||
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
||||
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
||||
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
|
||||
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
|
||||
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
|
||||
|
||||
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||
|
||||
## CLI management
|
||||
|
||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||
@@ -791,45 +713,6 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
|
||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||
|
||||
## MCP server sync
|
||||
|
||||
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
|
||||
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
|
||||
|
||||
Result (`data`):
|
||||
|
||||
- `applied` — `false` for the dry run.
|
||||
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
||||
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
|
||||
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
|
||||
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
|
||||
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
|
||||
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
|
||||
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
|
||||
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
|
||||
|
||||
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
|
||||
|
||||
## Webhook notifications
|
||||
|
||||
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | -------------------- | -------------------------------------------- | ----- |
|
||||
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
|
||||
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
|
||||
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
|
||||
|
||||
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-19
@@ -49,8 +49,6 @@ interface CliEntry {
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
||||
// that ended waiting for workers it will resume from
|
||||
// .modelDetect?: { screenLine, screenLines? }
|
||||
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -59,14 +57,10 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), and codex's ` <model> <effort> · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||
|
||||
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
|
||||
|
||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||
@@ -124,10 +118,6 @@ sure its row is one the agent cannot write.
|
||||
|
||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||
|
||||
## The newline chord
|
||||
|
||||
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||
|
||||
## Arg-template safety
|
||||
|
||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||
@@ -259,14 +249,6 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
||||
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
||||
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
||||
|
||||
## MCP server sync
|
||||
|
||||
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
||||
|
||||
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
||||
|
||||
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
|
||||
|
||||
## See also
|
||||
|
||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||
|
||||
@@ -201,18 +201,6 @@ 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.
|
||||
|
||||
Codeman does READ the route, for display only: a session header names the model
|
||||
the TUI's status line draws, and while it draws none (the status bar's model
|
||||
field switched off, or not painted yet) the model the session's route config
|
||||
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
|
||||
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
|
||||
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
|
||||
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
|
||||
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
|
||||
The reader never writes, follows no symlink out of the dsh home, and returns the
|
||||
model id alone. The TUI can still reject a pinned route against its provider's
|
||||
model catalog at startup; the status line, when on, then shows what it chose.
|
||||
|
||||
**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
|
||||
|
||||
@@ -532,16 +532,6 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
|
||||
---
|
||||
|
||||
## 10c. Webhook notifications (outbound channel)
|
||||
|
||||
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
|
||||
|
||||
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||
> closes anything (Close Session has no default key).
|
||||
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,7 @@ output. The other CLIs expose no equivalent.
|
||||
| Read My Mind | Yes | No |
|
||||
| Ralph loop and its task tracker | Yes | No |
|
||||
| Subagent and team windows | Yes | No |
|
||||
| Model, effort, advisor, and ultracode controls | Yes | No |
|
||||
| Model, effort, and ultracode controls | Yes | No |
|
||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
|
||||
| The bundled agent skill | Yes | No |
|
||||
|
||||
@@ -96,9 +96,6 @@ The defaults you will care about, all under **App Settings**:
|
||||
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
||||
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
||||
environment variable, because that would hard-lock it and block in-session switching.
|
||||
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
|
||||
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
|
||||
default: `/advisor` switches it or turns it off inside the session.
|
||||
- **Startup permission mode** (Agents & CLIs section). The default is
|
||||
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
||||
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
||||
|
||||
@@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:
|
||||
|
||||
| How | Result |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
|
||||
@@ -144,8 +144,6 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
|
||||
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
||||
curl -s "$API/api/subagents" | jq # background agents
|
||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||
|
||||
# with ID set to a session id:
|
||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||
|
||||
@@ -9,16 +9,13 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | --------------------------------------------------------------- |
|
||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||
| `Ctrl+W` | Kill the active session. |
|
||||
| `Ctrl+Tab` | Next session. |
|
||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||
|
||||
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
## Terminal
|
||||
|
||||
| Shortcut | Action |
|
||||
@@ -39,22 +36,6 @@ from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||
|
||||
## Tile grid
|
||||
|
||||
| Shortcut | Action |
|
||||
| --------------------------- | ------------------------------------------------------------ |
|
||||
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
|
||||
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
|
||||
|
||||
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
||||
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||
default. See [Tile Grid](Tile-Grid).
|
||||
|
||||
## Everything else
|
||||
|
||||
| Shortcut | Action |
|
||||
|
||||
@@ -7,12 +7,11 @@ opening the session.
|
||||
## The signals, cheapest first
|
||||
|
||||
| Surface | Reaches you | Default |
|
||||
| ------------------------------ | ------------------------------------------------ | ------- |
|
||||
| ---------------------- | ------------------------------------------------- | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||
| Approvals Inbox | One queue across every session | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
@@ -61,51 +60,6 @@ Setup:
|
||||
|
||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||
|
||||
## Webhooks: ntfy, Slack, Discord
|
||||
|
||||
**Opt-in, off by default. One channel for the whole server.**
|
||||
|
||||
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
|
||||
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
|
||||
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
|
||||
and for a team channel.
|
||||
|
||||
It carries the same events as push: permission prompts, questions, idle sessions, session
|
||||
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
|
||||
is included only when **Which events** is set to **Everything**; the default, **Needs
|
||||
attention**, skips it. A session that is watching its own work stays quiet here too.
|
||||
|
||||
Setup, in **App Settings → Notifications → Webhook**:
|
||||
|
||||
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
|
||||
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
|
||||
sessionId, sessionName, host, at }`.
|
||||
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
|
||||
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
|
||||
Send test saves anything you changed first, so it always tests what is on screen.
|
||||
|
||||
The status line under the group shows the last delivery: when it worked, or why it did not
|
||||
(an HTTP status, a timeout, a refused connection).
|
||||
|
||||
Behaviour worth knowing:
|
||||
|
||||
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
|
||||
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
|
||||
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
|
||||
never shows it again: once saved, the box is empty and the hint shows only the scheme and
|
||||
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
|
||||
(which also turns the channel off).
|
||||
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
|
||||
the only thing keeping strangers out.
|
||||
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
|
||||
Link-local and cloud-metadata addresses are refused, both when you save and when the
|
||||
message is sent, and redirects are not followed.
|
||||
- **Repeats are folded.** The same event for the same session within three seconds is sent
|
||||
once, so a flapping prompt cannot flood a channel.
|
||||
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
|
||||
the webhook, and it receives every user's session events (session names, tool names, error
|
||||
text). Point it somewhere every user would be comfortable with.
|
||||
|
||||
## The Approvals Inbox
|
||||
|
||||
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
||||
@@ -198,8 +152,7 @@ It is the morning-after view for an overnight run. Enable its header button in
|
||||
## Recommended setup for unattended runs
|
||||
|
||||
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
|
||||
webhook to ntfy if no browser will ever be open.
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
|
||||
3. Approvals Inbox on.
|
||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||
[Keeping Agents Running](Keeping-Agents-Running).
|
||||
@@ -209,8 +162,7 @@ from the lock screen.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
|
||||
has no such requirement, since the server sends it.
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
||||
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
||||
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
|
||||
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
||||
|
||||
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
||||
|
||||
| Tab | Use it when |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
|
||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
||||
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||
|
||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
||||
| To do this | Do that |
|
||||
| ------------------------- | ------------------------------------------------------------------- |
|
||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||
| Stop everything | `tmux -L codeman kill-server`. |
|
||||
|
||||
|
||||
@@ -50,30 +50,20 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
|
||||
|
||||
### Header & Panels
|
||||
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
||||
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
||||
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
|
||||
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
||||
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
||||
window; off lists every file by its full path.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
|
||||
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
|
||||
[Tile Grid](Tile-Grid).
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
|
||||
This section also holds background-agent tracking, including whether to track agents for
|
||||
every session or only the active tab.
|
||||
@@ -97,22 +87,13 @@ every session or only the active tab.
|
||||
|
||||
### Models
|
||||
|
||||
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
||||
advisor segment. The cards and the switch compose into one model choice, so there is no
|
||||
separate "which one wins" question.
|
||||
Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
|
||||
and the switch compose into one model choice, so there is no separate "which one wins"
|
||||
question.
|
||||
|
||||
Model, effort and advisor are all **soft defaults**: the model is written into the case's
|
||||
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
|
||||
`/effort` and `/advisor` inside a session override them at any time.
|
||||
|
||||
**Advisor** gives new Claude sessions Claude Code's
|
||||
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
|
||||
consults before committing to an approach, when an error keeps coming back, and before it
|
||||
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
|
||||
which costs less than running the stronger model all the time. **Default** leaves it to
|
||||
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
|
||||
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
|
||||
attached.
|
||||
Model and effort are both **soft defaults**: the model is written into the case's
|
||||
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
|
||||
inside a session override them at any time.
|
||||
|
||||
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||
@@ -131,13 +112,11 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
||||
| Animated status effects | Cosmetic. |
|
||||
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
|
||||
|
||||
### Notifications
|
||||
|
||||
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||
Master toggle, browser notifications, push subscription, audio alerts, and the idle
|
||||
threshold that decides when a quiet session counts as needing you. See
|
||||
[Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
### Voice
|
||||
@@ -154,10 +133,8 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
### System
|
||||
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
|
||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
mode, the **Users** administration entry is injected here.
|
||||
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
|
||||
**Users** administration entry is injected here.
|
||||
|
||||
## Session Options
|
||||
|
||||
@@ -192,8 +169,6 @@ Some things are configured before the server starts, not in the UI:
|
||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
||||
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
|
||||
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
|
||||
|
||||
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||
| Close | The tab's close control (no key by default) |
|
||||
| Close | `Ctrl+W` |
|
||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||
|
||||
Tabs can also be dragged to reorder.
|
||||
@@ -118,20 +118,12 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||
| Tiles | Off, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||
covered in [Mobile Guide](Mobile-Guide).
|
||||
|
||||
## Bottom bar
|
||||
|
||||
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
|
||||
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
|
||||
Panels → Bottom bar**. Click it for the Git window. See
|
||||
[Working With Files](Working-With-Files#git-changes).
|
||||
|
||||
## Connection state
|
||||
|
||||
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# Tile Grid
|
||||
|
||||
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
|
||||
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
||||
its agent is working, idle, or waiting on you.
|
||||
|
||||
The grid is a desktop feature. It needs a window at least about 1180px wide, and it is
|
||||
never offered in a popped-out session window.
|
||||
|
||||
## Turning it on
|
||||
|
||||
**App Settings → Header & Panels → Tiles.** This is a per-device setting, off by default,
|
||||
so turning it on at your desk never puts the button on your phone. It shows a **Tiles**
|
||||
button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
||||
|
||||
## Opening a grid
|
||||
|
||||
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
|
||||
(six until you choose; fewer if the window is too small or you have fewer sessions open).
|
||||
You get the grid you last had, its tiles where they were, topped up with your open
|
||||
sessions in tab order; if there is none, an open split's two first; otherwise your open
|
||||
sessions in tab order, with the session you are on focused. With the grid open, the same
|
||||
button closes it.
|
||||
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
|
||||
count it opens and what a click and a right-click do.
|
||||
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
|
||||
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
|
||||
and is what the next click opens. With the grid open, picking a count re-forms it: the
|
||||
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
|
||||
order. A count the window is too small for is greyed out, with the reason.
|
||||
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
|
||||
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
|
||||
the grid closed it opens what the Tiles button would show, with that session among them
|
||||
(still the count you chose in total). On macOS use
|
||||
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
|
||||
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
|
||||
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
|
||||
another tile swaps the two.
|
||||
- **"Open group as tiles"** in a tab group's menu, in the vertical tab rail with groups.
|
||||
- **Run**: a session you start from this browser tab's Run button while the grid is open
|
||||
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
||||
|
||||
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
||||
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
|
||||
window is too small for six; the count menu says which limit applies.
|
||||
|
||||
Opening, the tiles fade in one after another and each terminal appears once its history
|
||||
has loaded, rather than scrolling through it. Closing with the button, the tiles stay
|
||||
on screen, dimmed, until the single session behind them has loaded, then fade away. With
|
||||
reduced motion turned on in your system settings, the grid opens and closes at once.
|
||||
|
||||
## A tile
|
||||
|
||||
Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
|
||||
|
||||
| Part | What it does |
|
||||
| ------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
|
||||
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
|
||||
| name | Double-click to rename the session. |
|
||||
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. |
|
||||
| `⋯` | The session menu: options, open in a new window, close the session. |
|
||||
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
|
||||
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
|
||||
|
||||
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
|
||||
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
|
||||
voice and image paste. Tabs of tiled sessions carry a small underline.
|
||||
|
||||
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
|
||||
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
|
||||
one on its own until the window is big enough again.
|
||||
|
||||
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
|
||||
whose agent exited inside its pane says so instead; close that session from `⋯`.
|
||||
|
||||
## Moving tiles
|
||||
|
||||
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
|
||||
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
|
||||
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
|
||||
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
|
||||
header focuses its tile when you click it, not when you press it.
|
||||
|
||||
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
|
||||
down: into the empty slot if that is the place, else trading places with the tile there. It
|
||||
keeps the focus.
|
||||
|
||||
A moved tile takes the size of the place it lands in: column widths and row heights stay
|
||||
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
|
||||
the empty slot included, is saved with the grid and comes back on reload.
|
||||
|
||||
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
|
||||
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
|
||||
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
|
||||
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
|
||||
|
||||
## Keys
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------------ | ---------------------------------------------------------- |
|
||||
| `Ctrl+Shift+G` | Open or close the grid. |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
|
||||
| `Ctrl+L` | Clear the focused tile. |
|
||||
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). |
|
||||
|
||||
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
|
||||
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
|
||||
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
|
||||
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
|
||||
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
|
||||
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
|
||||
|
||||
## Leaving the grid
|
||||
|
||||
Clicking the tab of a session that is not tiled (or picking it with `Alt+1-9` or the
|
||||
session finder) shows that session on its own, the normal single view. The grid is
|
||||
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
|
||||
same. Narrowing the window below the desktop width also returns to the single view.
|
||||
|
||||
The grid is saved on this device and comes back when you reload the page, with its focus,
|
||||
zoom and column widths. A session that was closed in the meantime is simply left out.
|
||||
|
||||
Split shows the same logo, name and model above both of its panes.
|
||||
|
||||
The grid and Split are never open together: opening the grid turns an open split into two
|
||||
tiles, and Split is unavailable while the grid is open.
|
||||
|
||||
## Read next
|
||||
|
||||
- [The Dashboard](The-Dashboard) - the single view, tabs and the header.
|
||||
- [Keyboard Shortcuts](Keyboard-Shortcuts) - every binding.
|
||||
- [Settings Reference](Settings-Reference) - where the Tiles setting lives.
|
||||
@@ -163,41 +163,6 @@ HEIC images from an iPhone are converted to JPEG on the way in.
|
||||
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
|
||||
surface as an artifact attachment rather than a path you have to go and find.
|
||||
|
||||
## Git changes
|
||||
|
||||
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
||||
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
||||
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||
conflicts, `✓` when everything is committed and pushed.
|
||||
|
||||
Click it for a draggable window, in the style of the File Viewer:
|
||||
|
||||
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
|
||||
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
|
||||
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
|
||||
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
|
||||
not on remote"), which counts every commit on no remote rather than showing a green tick.
|
||||
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
|
||||
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
|
||||
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
|
||||
list of full paths instead.
|
||||
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
|
||||
coloured. Staged files show index versus last commit, not-staged files show working tree
|
||||
versus index, untracked files show as all additions and deleted files as all removals.
|
||||
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
||||
note instead, and a diff over 400 KB is cut short.
|
||||
- A session folder that holds several projects gets one collapsible section per repository
|
||||
found up to two levels down. They all start collapsed (each summary line shows its branch and
|
||||
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||
in your home folder) is ignored.
|
||||
|
||||
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
|
||||
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
|
||||
workspace is skipped even from a local session (a container can write there, and git would run
|
||||
that repository's own configuration on the host). The
|
||||
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
|
||||
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
**Using it**
|
||||
|
||||
- [The Dashboard](The-Dashboard)
|
||||
- [Tile Grid](Tile-Grid)
|
||||
- [Agent CLIs](Agent-CLIs)
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||
- [Working With Files](Working-With-Files)
|
||||
|
||||
Generated
+3
-16
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.35.0",
|
||||
"version": "1.33.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.35.0",
|
||||
"version": "1.33.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -32,7 +32,6 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"smol-toml": "^1.9.0",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
@@ -10115,18 +10114,6 @@
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
@@ -12385,7 +12372,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.35.0",
|
||||
"version": "1.33.3",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -105,7 +105,6 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"smol-toml": "^1.9.0",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -106,10 +106,10 @@ terminal.onData((data) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
|
||||
// Unconditional: rerender() is a no-op when there is nothing to draw, and
|
||||
// hasPending would miss an overlay that shows only an IME composition.
|
||||
terminal.onWriteParsed(() => zerolag.rerender());
|
||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
|
||||
terminal.onWriteParsed(() => {
|
||||
if (zerolag.hasPending) zerolag.rerender();
|
||||
});
|
||||
```
|
||||
|
||||
That is the whole integration. Everything below is for tuning it.
|
||||
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
|
||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||
```
|
||||
|
||||
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,9 +202,8 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
|--------|---------|-------------|
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
|
||||
### Backspace handling
|
||||
|
||||
@@ -218,19 +217,6 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
|
||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||
|
||||
### IME composition
|
||||
|
||||
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
|
||||
|
||||
```typescript
|
||||
const textarea = terminal.textarea!;
|
||||
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
|
||||
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
|
||||
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
|
||||
```
|
||||
|
||||
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
|
||||
|
||||
### Flushed text
|
||||
|
||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||
@@ -256,7 +242,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||
|
||||
### Prompt
|
||||
@@ -272,8 +258,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
|
||||
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||
|
||||
### Options
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
|
||||
@@ -58,7 +58,6 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
lines,
|
||||
compositionStart,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
@@ -91,24 +90,12 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||
// dropped along with that line when the tail is all that fits.
|
||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||
// Code-point offset of each line in the whole text, so the composition
|
||||
// styling survives the tail slice below.
|
||||
const lineOffsets: number[] = [];
|
||||
{
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
lineOffsets.push(offset);
|
||||
offset += [...line].length;
|
||||
}
|
||||
}
|
||||
let visibleLines = lines;
|
||||
let firstVisible = 0;
|
||||
let keepsPromptLine = true;
|
||||
let topRow = promptRow;
|
||||
if (rows && rows > 0) {
|
||||
if (lines.length > rows) {
|
||||
firstVisible = lines.length - rows;
|
||||
visibleLines = lines.slice(firstVisible);
|
||||
visibleLines = lines.slice(lines.length - rows);
|
||||
keepsPromptLine = false;
|
||||
topRow = 0;
|
||||
} else if (promptRow + lines.length > rows) {
|
||||
@@ -129,21 +116,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
topPx,
|
||||
widthPx,
|
||||
cellH,
|
||||
cellW,
|
||||
charTop,
|
||||
charHeight,
|
||||
font,
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
@@ -171,10 +144,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||
* composition text: underlined, like xterm's own composition view, and marked
|
||||
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
* CJK wide characters occupy 2 cell widths.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -186,8 +156,7 @@ function makeLine(
|
||||
_charTop: number,
|
||||
_charHeight: number,
|
||||
font: FontStyle,
|
||||
terminal?: XtermTerminal | null,
|
||||
compositionFrom?: number
|
||||
terminal?: XtermTerminal | null
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
@@ -203,7 +172,6 @@ function makeLine(
|
||||
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
const span = document.createElement('span');
|
||||
@@ -221,15 +189,9 @@ function makeLine(
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||
span.style.textDecoration = 'underline';
|
||||
span.setAttribute('data-zerolag-composition', '');
|
||||
span.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
colOffset += cw;
|
||||
index++;
|
||||
}
|
||||
|
||||
return el;
|
||||
|
||||
@@ -163,12 +163,6 @@ export interface CellDimensions {
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
/**
|
||||
* Index (in code points, across all `lines`) where IME composition text
|
||||
* begins. Characters from there on are drawn underlined and marked
|
||||
* `data-zerolag-composition`. Omit when nothing is being composed.
|
||||
*/
|
||||
compositionStart?: number;
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
|
||||
@@ -67,8 +67,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
private _flushedOffset = 0;
|
||||
private _flushedText = '';
|
||||
private _bufferDetectDone = false;
|
||||
// IME text still being composed: drawn after the pending text, never sent.
|
||||
private _composition = '';
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
@@ -132,7 +130,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._hasContent()) {
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
@@ -208,14 +206,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
||||
* The consumer SHOULD send backspace to the PTY.
|
||||
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
||||
*
|
||||
* Any IME composition is dropped in every case, and the overlay is repainted
|
||||
* without it (hidden when nothing else is left).
|
||||
*/
|
||||
removeChar(): 'pending' | 'flushed' | false {
|
||||
// A backspace that reaches the overlay means no composition is open.
|
||||
const droppedComposition = this._composition.length > 0;
|
||||
this._composition = '';
|
||||
if (this._pendingText.length > 0) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||
@@ -251,9 +243,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return 'flushed';
|
||||
}
|
||||
|
||||
// Nothing to remove, but a composition-only overlay is still on screen
|
||||
// drawing the text dropped above.
|
||||
if (droppedComposition) this._hide();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -263,7 +252,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._composition = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
@@ -309,7 +297,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText || this._composition) {
|
||||
if (this._pendingText) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
@@ -324,7 +312,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._hasContent()) {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
@@ -337,7 +325,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._hasContent()) this._render();
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
@@ -403,37 +391,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
this._options.prompt = finder;
|
||||
this._lastPromptPos = null;
|
||||
this._lastRenderKey = '';
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── IME composition ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show text an IME is still composing as an underlined tail after the
|
||||
* pending text, wrapped and kept on screen like the rest of the overlay.
|
||||
* Pass `''` to remove it.
|
||||
*
|
||||
* Visual only: the composition is never part of `pendingText`, `hasPending`
|
||||
* or anything a consumer sends. When the IME commits, the consumer adds the
|
||||
* committed text the usual way (`addChar`/`appendText`) and clears the
|
||||
* composition. `clear()` and `removeChar()` drop it too.
|
||||
*/
|
||||
setComposition(text: string): void {
|
||||
// One visual line of provisional text: control characters and line breaks
|
||||
// would break the cell grid.
|
||||
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
|
||||
if (next === this._composition) return;
|
||||
this._composition = next;
|
||||
if (this._hasContent()) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
|
||||
get composition(): string {
|
||||
return this._composition;
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
@@ -467,13 +425,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return this._pendingText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is pending or flushed text. Excludes the IME composition,
|
||||
* which is never sent, so an overlay showing only a composition reports
|
||||
* `false` while still on screen. To re-place the overlay after output or a
|
||||
* resize, call `rerender()` unconditionally: it is a no-op when there is
|
||||
* nothing to draw.
|
||||
*/
|
||||
/** Whether there is any overlay content (pending or flushed). */
|
||||
get hasPending(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||
}
|
||||
@@ -491,10 +443,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
// ─── Private methods ──────────────────────────────────────────────
|
||||
|
||||
private _hasContent(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
|
||||
}
|
||||
|
||||
private _getPromptOffset(): number {
|
||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||
return prompt.offset ?? 2;
|
||||
@@ -557,7 +505,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._hasContent()) {
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -615,16 +563,12 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
}
|
||||
}
|
||||
|
||||
// The composition is a styled tail after everything the user has typed.
|
||||
const compositionStart = [...displayText].length;
|
||||
displayText += this._composition;
|
||||
|
||||
// Skip redundant re-renders — include text content to detect
|
||||
// same-length changes (e.g., setFlushed with different text)
|
||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||
// changing the text — must not be skipped as a redundant render.
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -664,7 +608,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
compositionStart: this._composition ? compositionStart : undefined,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||
// after the pending text. Visual only, never part of what a consumer sends.
|
||||
|
||||
const CELL_W = 10;
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: opts.lines ?? ['$ '] },
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
cellWidth: CELL_W,
|
||||
cellHeight: 20,
|
||||
});
|
||||
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
|
||||
return { addon, mock, overlay };
|
||||
}
|
||||
|
||||
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
|
||||
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
|
||||
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||
}
|
||||
|
||||
function lineText(line: HTMLDivElement): string {
|
||||
return Array.from(line.children)
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function compositionText(overlay: HTMLDivElement): string {
|
||||
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('setComposition', () => {
|
||||
it('renders the composition after pendingText, underlined and aria-hidden', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
|
||||
const [line] = lineDivs(overlay);
|
||||
expect(lineText(line)).toBe('abcxy');
|
||||
const spans = Array.from(line.children) as HTMLSpanElement[];
|
||||
for (const span of spans.slice(0, 3)) {
|
||||
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
|
||||
expect(span.style.textDecoration).toBe('');
|
||||
}
|
||||
for (const span of spans.slice(3)) {
|
||||
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
|
||||
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(span.style.textDecoration).toBe('underline');
|
||||
}
|
||||
// Grid positions continue straight on from the pending text.
|
||||
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||
expect(overlay.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('places a wide composition by cell width after wide pending text', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('今日は');
|
||||
addon.setComposition('天気');
|
||||
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||
});
|
||||
|
||||
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||
const { addon } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setFlushed(2, 'zz');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
|
||||
expect(addon.composition).toBe('xy');
|
||||
expect(addon.state.pendingText).toBe('abc');
|
||||
expect(addon.state.flushedText).toBe('zz');
|
||||
});
|
||||
|
||||
it('shows on an empty prompt without making anything pending', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('かな');
|
||||
expect(addon.pendingText).toBe('');
|
||||
expect(addon.hasPending).toBe(false);
|
||||
expect(addon.state.visible).toBe(true);
|
||||
expect(compositionText(overlay)).toBe('かな');
|
||||
});
|
||||
|
||||
it('wraps with the pending text: the tail continues onto the next line', () => {
|
||||
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
|
||||
const { addon, overlay } = setup({ cols: 12 });
|
||||
addon.appendText('abcdefgh');
|
||||
addon.setComposition('WXYZ');
|
||||
const lines = lineDivs(overlay);
|
||||
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
|
||||
expect(compositionText(overlay)).toBe('WXYZ');
|
||||
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||
expect(second[0].style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||
// 2 visible rows, 3 lines of text: the first line is dropped.
|
||||
const { addon, overlay } = setup({ cols: 6, rows: 2 });
|
||||
addon.appendText('abcdefghij');
|
||||
addon.setComposition('XYZ');
|
||||
const lines = lineDivs(overlay);
|
||||
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
|
||||
expect(compositionText(overlay)).toBe('XYZ');
|
||||
const first = Array.from(lines[0].children);
|
||||
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
|
||||
});
|
||||
|
||||
it("setComposition('') removes the tail and keeps the pending text", () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.setComposition('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
});
|
||||
|
||||
it("setComposition('') on an otherwise empty overlay hides it", () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('xy');
|
||||
addon.setComposition('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
expect(overlay.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.clear();
|
||||
expect(addon.composition).toBe('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
addon.addChar('q');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('q');
|
||||
});
|
||||
|
||||
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.removeChar()).toBe('pending');
|
||||
expect(addon.pendingText).toBe('ab');
|
||||
expect(addon.composition).toBe('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||
});
|
||||
|
||||
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('ka');
|
||||
expect(compositionText(overlay)).toBe('ka');
|
||||
expect(addon.removeChar()).toBe(false);
|
||||
expect(addon.composition).toBe('');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
expect(addon.state.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('removeChar() repaints flushed text without the dropped composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setFlushed(3, 'abc');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.removeChar()).toBe('flushed');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||
});
|
||||
|
||||
it('text appended while composing lands before the tail', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('ab');
|
||||
addon.setComposition('xy');
|
||||
addon.addChar('c');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
});
|
||||
|
||||
it('rerender() and refreshFont() keep the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.rerender();
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
addon.refreshFont();
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
});
|
||||
|
||||
it('re-renders when only the composition changes', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('x');
|
||||
addon.setComposition('xy');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
});
|
||||
|
||||
it('strips control characters and line breaks from the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('a\nb\u0007c
');
|
||||
expect(addon.composition).toBe('abc');
|
||||
expect(compositionText(overlay)).toBe('abc');
|
||||
});
|
||||
|
||||
it('draws the block cursor after the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('ab');
|
||||
addon.setComposition('xy');
|
||||
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||
// prompt col 0 + offset 2 + 4 cells
|
||||
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.35.0",
|
||||
"version": "1.33.3",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.30.1 (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
|
||||
@@ -196,11 +196,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# 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.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
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
|
||||
@@ -212,19 +207,12 @@ spawn_worker() {
|
||||
# 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-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)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (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; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
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
|
||||
@@ -384,10 +372,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.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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
|
||||
@@ -438,7 +426,7 @@ 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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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'
|
||||
@@ -505,12 +493,6 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||
stronger model it consults before committing to an approach, on a recurring error and
|
||||
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||
`fable` do anything).
|
||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.30.1 (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
|
||||
@@ -118,11 +118,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# 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.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
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
|
||||
@@ -134,19 +129,12 @@ spawn_worker() {
|
||||
# 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-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)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (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; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
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
|
||||
@@ -306,4 +294,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.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
|
||||
@@ -345,13 +345,6 @@ ESC=$(printf '\033')
|
||||
`.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 claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||
`CODEMAN_WORKER_ADVISOR` is set.
|
||||
|
||||
⚠️ 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`,
|
||||
@@ -391,18 +384,17 @@ every claude create path installs them, so a linked case and a raw path both get
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
`envOverrides`). Three differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
(`session-routes.ts:648`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -95,9 +95,6 @@ Differences from `quick-start` worth knowing before you debug one:
|
||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||
one that does not answer or cannot be read (an unreachable network mount, a
|
||||
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||
create a replacement for it;
|
||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
|
||||
@@ -83,11 +83,9 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
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 tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.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');
|
||||
@@ -113,10 +111,8 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'mobile-ime-preview.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'tab-layout-browser.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
'terminal-ui.js',
|
||||
|
||||
+8
-26
@@ -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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.30.1 (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
|
||||
@@ -196,11 +196,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# 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.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
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
|
||||
@@ -212,19 +207,12 @@ spawn_worker() {
|
||||
# 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-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)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (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; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
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
|
||||
@@ -384,10 +372,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.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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
|
||||
@@ -438,7 +426,7 @@ 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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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'
|
||||
@@ -505,12 +493,6 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||
stronger model it consults before committing to an approach, on a recurring error and
|
||||
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||
`fable` do anything).
|
||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.30.1 (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
|
||||
@@ -118,11 +118,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# 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.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
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
|
||||
@@ -134,19 +129,12 @@ spawn_worker() {
|
||||
# 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-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)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (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; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
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
|
||||
@@ -306,4 +294,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.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
|
||||
@@ -345,13 +345,6 @@ ESC=$(printf '\033')
|
||||
`.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 claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||
`CODEMAN_WORKER_ADVISOR` is set.
|
||||
|
||||
⚠️ 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`,
|
||||
@@ -391,18 +384,17 @@ every claude create path installs them, so a linked case and a raw path both get
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
`envOverrides`). Three differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
(`session-routes.ts:648`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -95,9 +95,6 @@ Differences from `quick-start` worth knowing before you debug one:
|
||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||
one that does not answer or cannot be read (an unreachable network mount, a
|
||||
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||
create a replacement for it;
|
||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
|
||||
@@ -119,16 +119,3 @@ export function compileVersionRegex(source: string): RegExp | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many capture groups a regex source declares (named ones included), or -1 when it
|
||||
* does not compile. Matching the empty string against `source|` always succeeds through
|
||||
* the empty alternative, and the match array then has one slot per group.
|
||||
*/
|
||||
export function countCaptureGroups(source: string): number {
|
||||
try {
|
||||
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -28,14 +27,6 @@ const envName = z
|
||||
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
|
||||
.max(64);
|
||||
|
||||
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
|
||||
const mcpRelativePath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
||||
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
|
||||
|
||||
/**
|
||||
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
|
||||
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
|
||||
@@ -370,43 +361,6 @@ const capabilitiesSchema = z
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
.strict(),
|
||||
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
|
||||
// over the foot of a pane capture every time a session settles. Exactly one capture
|
||||
// group (the model), checked here so a pattern without one fails at LOAD time instead
|
||||
// of silently never naming a model.
|
||||
modelDetect: z
|
||||
.object({
|
||||
screenLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
|
||||
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
|
||||
)
|
||||
.optional(),
|
||||
// Bounded hard, like watchingLines: every row it adds is one more row the agent
|
||||
// itself may be able to write.
|
||||
screenLines: z.number().int().min(1).max(4).optional(),
|
||||
// Single tokens, bounded: each is compared against one captured field.
|
||||
rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(),
|
||||
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
|
||||
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
|
||||
})
|
||||
.strict()
|
||||
// Typos rather than configurations, refused at LOAD time like watchingLines.
|
||||
.refine(
|
||||
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
|
||||
'modelDetect declares nothing to read'
|
||||
)
|
||||
.refine(
|
||||
(v) => v.screenLines === undefined || v.screenLine !== undefined,
|
||||
'screenLines has nothing to bound without a screenLine'
|
||||
)
|
||||
.refine(
|
||||
(v) => v.rejectWords === undefined || v.screenLine !== undefined,
|
||||
'rejectWords has nothing to filter without a screenLine'
|
||||
)
|
||||
.optional(),
|
||||
privilegedParams: z
|
||||
.array(
|
||||
z
|
||||
@@ -423,26 +377,6 @@ const capabilitiesSchema = z
|
||||
privilegedEnvKeys: z.array(envName).max(8),
|
||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||
maxFrameBytes: z.number().int().positive().optional(),
|
||||
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
||||
mcpConfig: z
|
||||
.object({
|
||||
// Home-relative, no traversal: sync writes to this path.
|
||||
path: mcpRelativePath,
|
||||
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||
format: z.enum([
|
||||
'claude-json',
|
||||
'gemini-json',
|
||||
'codex-toml',
|
||||
'opencode-json',
|
||||
'antigravity-json',
|
||||
] as const satisfies readonly McpConfigFormat[]),
|
||||
// The env var the CLI reads to move the file, and the path under it (same no-traversal
|
||||
// rule: sync writes there too). Resolved from the server env at call time, never here.
|
||||
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
customModelInjection: z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
|
||||
import type { CliEntry } from './types.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
|
||||
|
||||
const HOME_DIRS = {
|
||||
local: '~/.local/bin',
|
||||
@@ -307,12 +306,6 @@ const CLAUDE: CliEntry = {
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
],
|
||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||
mcpConfig: {
|
||||
path: '.claude.json',
|
||||
format: 'claude-json',
|
||||
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||
},
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
||||
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
||||
// respawn, never a live hot-swap.
|
||||
@@ -493,12 +486,6 @@ const OPENCODE: CliEntry = {
|
||||
...agentDefaults(),
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||
mcpConfig: {
|
||||
path: '.config/opencode/opencode.json',
|
||||
format: 'opencode-json',
|
||||
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
},
|
||||
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
||||
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
||||
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
||||
@@ -545,7 +532,6 @@ const CODEX: CliEntry = {
|
||||
bypassApprovals: { type: 'bool' },
|
||||
animations: { type: 'bool' },
|
||||
model: { type: 'token', pattern: 'model' },
|
||||
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||
resumeId: { type: 'token', pattern: 'id' },
|
||||
},
|
||||
variants: [
|
||||
@@ -557,14 +543,6 @@ const CODEX: CliEntry = {
|
||||
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
|
||||
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
|
||||
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||
// One literal per level: an argv token cannot splice a value into a literal, and
|
||||
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
|
||||
// what admits a level, so an unknown one emits nothing.
|
||||
...CODEX_REASONING_EFFORTS.map((level) => ({
|
||||
flag: '--config',
|
||||
value: `model_reasoning_effort=${level}`,
|
||||
when: { param: 'reasoningEffort', is: level },
|
||||
})),
|
||||
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
|
||||
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||
],
|
||||
@@ -622,16 +600,6 @@ const CODEX: CliEntry = {
|
||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||
watchingLines: 3,
|
||||
},
|
||||
// The footer under the composer, measured on a live 0.147.0 pane:
|
||||
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
|
||||
// cwd). It is the pane's LAST row, below the composer, so the transcript never
|
||||
// reaches it, and the effort word right after the model is codex's own format: an
|
||||
// open slash-command popup or a bare line of prose does not have that shape. A
|
||||
// footer without an effort word (a model with no reasoning setting) is not read,
|
||||
// and the session keeps its last known or launch model.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `,
|
||||
},
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
@@ -652,11 +620,6 @@ const CODEX: CliEntry = {
|
||||
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
||||
// regression; `schema.ts` now rejects a name that is not a declared param.
|
||||
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
||||
mcpConfig: {
|
||||
path: '.codex/config.toml',
|
||||
format: 'codex-toml',
|
||||
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||
},
|
||||
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
||||
// so the user's real ~/.codex/config.toml is never touched.
|
||||
customModelInjection: {
|
||||
@@ -758,12 +721,6 @@ const GEMINI: CliEntry = {
|
||||
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
||||
// sends no geminiConfig at all would still get yolo for free.
|
||||
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
||||
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
|
||||
mcpConfig: {
|
||||
path: '.gemini/settings.json',
|
||||
format: 'gemini-json',
|
||||
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||
},
|
||||
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
||||
// start). Confirm the exact model-override env var name against the installed
|
||||
// gemini-cli version before shipping.
|
||||
@@ -843,8 +800,6 @@ const ANTIGRAVITY: CliEntry = {
|
||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||
// SENT config needs the flag forced off — nothing is materialized.
|
||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
|
||||
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
|
||||
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
||||
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
||||
// reasoning model. Toolbar entry stays disabled for this mode.
|
||||
@@ -1232,35 +1187,6 @@ const DEEPSEEK: CliEntry = {
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Model is NOT a session field for dsh — it is a profile composition entry.
|
||||
model: { source: 'none' },
|
||||
// So the screen is where the model is known: dsh-TUI resolves the route itself
|
||||
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
|
||||
// lib/types/modelRoute.js) and its status line draws "the route requests actually
|
||||
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
|
||||
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
|
||||
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
|
||||
// The border anchors it: nothing the agent writes can sit below the composer, and a
|
||||
// suggestion popup there starts with `/` or `+`, never a model id.
|
||||
// ⚠ The first field is the model only while the status bar's model field is on (the
|
||||
// default). Switched off, the first field is the next one (StatusLine.js): tokens per
|
||||
// second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match,
|
||||
// then the reasoning effort (` medium · th-config`, measured live), then the session
|
||||
// mode, then the cwd's basename. So `rejectWords` lists what those can be, from the
|
||||
// dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's
|
||||
// THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode
|
||||
// ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token,
|
||||
// and a field equal to the session's folder name is refused by the shared reader.
|
||||
// Known gaps, all off by default: a custom mode id drawn raw, a git branch or a
|
||||
// one-word session title as the first field; and the non-compact layout, whose
|
||||
// left/right justification never ends a field with ` · `, so nothing is read there
|
||||
// and the session shows its route config.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
||||
screenLines: 3,
|
||||
rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'],
|
||||
// With the status bar's model field off (or before it paints), the route the
|
||||
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
|
||||
configResolver: 'deepseek-route',
|
||||
},
|
||||
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||
// `read-only` instead would break the workspace rather than protect it.
|
||||
|
||||
@@ -90,15 +90,6 @@ export interface CliVariant {
|
||||
args: ArgSpec[];
|
||||
}
|
||||
|
||||
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||
|
||||
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
|
||||
export type ModelConfigResolverName = 'deepseek-route';
|
||||
|
||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
|
||||
export interface CliLaunch {
|
||||
params: Record<string, ParamSpec>;
|
||||
/**
|
||||
@@ -465,41 +456,6 @@ export interface CliCapabilities {
|
||||
statusLineTelemetry: boolean;
|
||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||
/**
|
||||
* Where this CLI draws the model it is running, so a session header can name it
|
||||
* (`SessionState.displayModel`, src/session-display-model.ts).
|
||||
*
|
||||
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
|
||||
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
|
||||
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
|
||||
* extra tmux call and re-reads the footer at every turn transition, so an in-session
|
||||
* `/model` switch is followed.
|
||||
*
|
||||
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
|
||||
* on chrome only this CLI draws (the row under its own composer, an effort word in its
|
||||
* own footer format), never on a shape the agent could print in its transcript. Measured
|
||||
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
|
||||
* the session shows its launch model, if any.
|
||||
*
|
||||
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
|
||||
* model the CLI's own config pins, the way that CLI resolves it for the session, for
|
||||
* while the screen names none (its status line switched off, or not drawn yet). Read
|
||||
* once per pane start, attach or relaunch, bounded and read-only; the screen still
|
||||
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
|
||||
* per-CLI behaviour stays data here and code in one module.
|
||||
*/
|
||||
modelDetect?: {
|
||||
screenLine?: string;
|
||||
screenLines?: number;
|
||||
/**
|
||||
* Words the `screenLine` field can show when it is NOT the model (a footer whose model
|
||||
* field is switched off shows the next field there), compared lower-cased. A field
|
||||
* equal to the session's own working-directory basename is never the model either,
|
||||
* for every CLI; that rule is the shared reader's, not data.
|
||||
*/
|
||||
rejectWords?: string[];
|
||||
configResolver?: ModelConfigResolverName;
|
||||
};
|
||||
/**
|
||||
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
||||
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||
@@ -555,28 +511,6 @@ export interface CliCapabilities {
|
||||
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
||||
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
||||
maxFrameBytes?: number;
|
||||
/**
|
||||
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
|
||||
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
|
||||
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
|
||||
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
|
||||
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
|
||||
*/
|
||||
newline?: NewlineSequence;
|
||||
/**
|
||||
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
|
||||
* `path` is relative to the home directory. `format` names the file dialect the sync
|
||||
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
||||
* skipped by sync rather than guessed at.
|
||||
*
|
||||
* `relocation` names the env var the CLI itself reads to move that file (codex's
|
||||
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
|
||||
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
|
||||
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
|
||||
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
|
||||
* only follows `$HOME`.
|
||||
*/
|
||||
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
|
||||
/**
|
||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
* @module config/dependency-registry
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { enabledClis } from './cli-registry/registry.js';
|
||||
import { compileVersionRegex } from './cli-registry/patterns.js';
|
||||
|
||||
@@ -32,24 +30,6 @@ export interface PathResolver {
|
||||
* there and a false "installed" contradicts the run mode's own resolver.
|
||||
*/
|
||||
requireVersionMatch?: boolean;
|
||||
/**
|
||||
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
|
||||
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
|
||||
* prefix is invisible to `which` while the run mode, which falls back to the registry's
|
||||
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
|
||||
*/
|
||||
searchDirs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
|
||||
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
|
||||
* would pull in the whole resolver chain; keep the two in step.
|
||||
*/
|
||||
function expandSearchDir(dir: string): string {
|
||||
if (dir === '~') return homedir();
|
||||
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
||||
@@ -151,7 +131,6 @@ function cliDependencyEntries(): ToolDependency[] {
|
||||
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
|
||||
// program, so a version mismatch means MISSING rather than unknown-version.
|
||||
requireVersionMatch: version?.requireVersionMatch,
|
||||
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
|
||||
*
|
||||
* A linked case can live on a network mount, and a hard mount that went away makes
|
||||
* `stat()` wait until the mount comes back. The probe gives up on such a path after
|
||||
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
|
||||
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
|
||||
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
|
||||
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
|
||||
*
|
||||
* Both are env-overridable, in the same style as the other config modules. A slow
|
||||
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
|
||||
* a longer timeout. The stall limits follow `UV_THREADPOOL_SIZE` on their own, so a
|
||||
* server started with a larger pool gets a higher ceiling without further setup.
|
||||
*
|
||||
* @module config/path-probe
|
||||
*/
|
||||
|
||||
function envInt(name: string, fallback: number, min: number, max: number): number {
|
||||
const raw = parseInt(process.env[name] || '', 10);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return fallback;
|
||||
return Math.max(min, Math.min(max, raw));
|
||||
}
|
||||
|
||||
/** How long a caller waits for one path probe before the answer is "unknown". */
|
||||
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
|
||||
|
||||
/**
|
||||
* Hard ceiling on timed-out probes left pending, for every caller, `pastCap` ones
|
||||
* included: the threadpool size minus one, so a dead mount can never take the last
|
||||
* worker. libuv sizes the pool from `UV_THREADPOOL_SIZE` (4 when unset). A pool of
|
||||
* one cannot keep a worker free at all, so the ceiling never drops below one.
|
||||
*/
|
||||
export const PATH_PROBE_STALL_CEILING = Math.max(1, (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1);
|
||||
|
||||
/**
|
||||
* Timed-out probes allowed to stay pending before new BULK probes are refused
|
||||
* (answered "unknown" without a stat). This is a backstop, not the main defence: a
|
||||
* stalled path on a network or FUSE mount already takes the rest of that mount out
|
||||
* of probing (a stall anywhere else takes out only the stalled path), so the cap
|
||||
* only engages once that many UNRELATED places have stopped answering. It defaults
|
||||
* to one below {@link PATH_PROBE_STALL_CEILING} (2 with the default pool), leaving a
|
||||
* slot a `pastCap` probe may still use, and is never allowed above the ceiling.
|
||||
*/
|
||||
export const MAX_STALLED_PATH_PROBES = Math.min(
|
||||
PATH_PROBE_STALL_CEILING,
|
||||
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
|
||||
);
|
||||
@@ -1,464 +0,0 @@
|
||||
/**
|
||||
* @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to
|
||||
* use, read from its route config, for a session header whose screen names no model
|
||||
* yet (the status bar's model field switched off, or not drawn yet). See
|
||||
* `SessionState.displayModel` (src/session-display-model.ts): the screen still wins
|
||||
* whenever it names a model, since it is what the running TUI actually uses.
|
||||
*
|
||||
* ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1)
|
||||
*
|
||||
* A profile is a stack of loader patch layers over an empty root, in this order
|
||||
* (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own
|
||||
* `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the home-level
|
||||
* `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch`
|
||||
* overlays (Codeman passes none). A patch targets a row by `id`; one whose `name`
|
||||
* does not match the row's is skipped; every other key REPLACES the row's field
|
||||
* whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui`
|
||||
* row defines all of it.
|
||||
*
|
||||
* dsh-TUI then takes its model route from that config only when it names BOTH
|
||||
* `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is
|
||||
* dropped whole and the TUI falls back to the persisted `/model` choice, then to its
|
||||
* own default: neither is in the config, so neither is answered here. The bundle's
|
||||
* own row pins `provider: deepseek-official` alone, by design, so only the two user
|
||||
* layers can pin a route; the bundle layers are not read (they resolve through the dsh
|
||||
* installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the
|
||||
* HEADLESS default, not the TUI's, and is never read.
|
||||
*
|
||||
* ## Answer nothing rather than a guess
|
||||
*
|
||||
* Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned
|
||||
* route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too
|
||||
* big, a mount that does not answer), and a file this reader does not fully
|
||||
* understand. The YAML reader below is deliberately narrow (the repo carries no YAML
|
||||
* dependency): a top-level block sequence of patch items, plain keys, single-line
|
||||
* plain or quoted string scalars for the values it needs, and null for anything else
|
||||
* that could change the answer (an anchor, alias or tag such as `!!js` on such a value,
|
||||
* a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a
|
||||
* scalar YAML would type as a number, boolean or null, a second document, a nested
|
||||
* row redefining dsh-TUI).
|
||||
*
|
||||
* ## Never block, never write, never leak
|
||||
*
|
||||
* Every path is probed with the bounded `probePathKind()` before it is touched, read
|
||||
* asynchronously with a size cap, and must resolve (realpath) inside the dsh home.
|
||||
* Nothing is written. Only the model id leaves this module: never the provider, a
|
||||
* base URL, a key or any other config value.
|
||||
*
|
||||
* Tests: `test/deepseek-route-config.test.ts`.
|
||||
*
|
||||
* @module deepseek-route-config
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import {
|
||||
deepSeekProfileFromManifest,
|
||||
isProfileDirName,
|
||||
resolveDefaultDeepSeekProfile,
|
||||
type DeepSeekProfile,
|
||||
} from './utils/deepseek-cli-resolver.js';
|
||||
|
||||
/** The dsh-TUI bundle a profile must compose for its route to be read here. */
|
||||
export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
|
||||
/** The loader row dsh-TUI's own config lives on. */
|
||||
export const DSH_TUI_ROW_ID = 'dsh-tui';
|
||||
/** Largest file read: a patch layer is a few dozen lines. */
|
||||
export const MAX_ROUTE_FILE_BYTES = 64 * 1024;
|
||||
/** A profile name as the launch accepts it (the `path-segment` token pattern). */
|
||||
const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||
/** How many profile directories the default-profile inventory looks at. */
|
||||
const MAX_PROFILES = 64;
|
||||
|
||||
/** What one patch item does to the dsh-TUI row. */
|
||||
export interface DshTuiRowPatch {
|
||||
/** `name` on the patch; a mismatch makes dsh skip it. */
|
||||
name?: string;
|
||||
/** `disabled` on the patch, when present. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* `config` on the patch, when present: the provider and model it names (absent when
|
||||
* it does not name one), or `{}` for a config that is empty or not a mapping.
|
||||
*/
|
||||
config?: { provider?: string; model?: string };
|
||||
}
|
||||
|
||||
/** Thrown inside the parser for anything it does not fully understand. */
|
||||
class Ambiguous extends Error {}
|
||||
|
||||
/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */
|
||||
const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/;
|
||||
|
||||
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
|
||||
|
||||
/**
|
||||
* Strip a line's comment (`#` at line start or after whitespace, outside quotes) and
|
||||
* trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is
|
||||
* beyond this reader.
|
||||
*/
|
||||
function stripComment(line: string): string {
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (quote === "'") {
|
||||
if (c === "'") {
|
||||
if (line[i + 1] === "'") i++;
|
||||
else quote = null;
|
||||
}
|
||||
} else if (quote === '"') {
|
||||
if (c === '\\') i++;
|
||||
else if (c === '"') quote = null;
|
||||
} else if (c === "'" || c === '"') {
|
||||
// A quote opens a scalar only at its start; inside a plain scalar it is a character.
|
||||
const prev = line.slice(0, i).trimEnd();
|
||||
if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c;
|
||||
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||
return line.slice(0, i).trimEnd();
|
||||
}
|
||||
}
|
||||
if (quote) throw new Ambiguous('unterminated quote');
|
||||
return line.trimEnd();
|
||||
}
|
||||
|
||||
/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */
|
||||
function indentOf(line: string): number {
|
||||
const m = /^[ \t]*/.exec(line)![0];
|
||||
if (m.includes('\t')) throw new Ambiguous('tab indentation');
|
||||
return m.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-line scalar as a string: plain, or single/double-quoted. Throws on anything
|
||||
* that is not plainly a string (a tag, an anchor, an alias, a flow collection, a
|
||||
* block scalar, or a plain scalar YAML would type as null, a boolean or a number).
|
||||
*/
|
||||
function stringScalar(raw: string): string {
|
||||
const v = raw.trim();
|
||||
if (v.startsWith("'")) {
|
||||
const m = /^'((?:[^']|'')*)'$/.exec(v);
|
||||
if (!m) throw new Ambiguous('quoted scalar');
|
||||
return m[1].replace(/''/g, "'");
|
||||
}
|
||||
if (v.startsWith('"')) {
|
||||
const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v);
|
||||
if (!m) throw new Ambiguous('quoted scalar');
|
||||
return m[1].replace(/\\(["\\/])/g, '$1');
|
||||
}
|
||||
if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string');
|
||||
if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar');
|
||||
if (
|
||||
/^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test(
|
||||
v
|
||||
)
|
||||
) {
|
||||
throw new Ambiguous('numeric scalar');
|
||||
}
|
||||
if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator');
|
||||
return v;
|
||||
}
|
||||
|
||||
/** `true`/`false` as YAML spells them, or a throw. */
|
||||
function boolScalar(raw: string): boolean {
|
||||
const v = raw.trim();
|
||||
if (/^(?:true|True|TRUE)$/.test(v)) return true;
|
||||
if (/^(?:false|False|FALSE)$/.test(v)) return false;
|
||||
throw new Ambiguous('not a boolean');
|
||||
}
|
||||
|
||||
interface Line {
|
||||
indent: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The direct keys of a block mapping whose lines all sit at `indent` or deeper, each
|
||||
* with its inline value and the lines nested under it. Throws on a line that is not a
|
||||
* key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh
|
||||
* would not boot).
|
||||
*/
|
||||
function mappingKeys(lines: Line[], indent: number): Map<string, { inline: string | undefined; nested: Line[] }> {
|
||||
const keys = new Map<string, { inline: string | undefined; nested: Line[] }>();
|
||||
let current: { inline: string | undefined; nested: Line[] } | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.indent > indent) {
|
||||
if (!current) throw new Ambiguous('nested line with no key');
|
||||
current.nested.push(line);
|
||||
continue;
|
||||
}
|
||||
if (line.indent < indent) throw new Ambiguous('dedent inside a mapping');
|
||||
const m = KEY_LINE.exec(line.text);
|
||||
if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`);
|
||||
if (keys.has(m[1])) throw new Ambiguous('duplicate key');
|
||||
current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] };
|
||||
keys.set(m[1], current);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item this reader cannot follow is certainly about another row: a plain
|
||||
* `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no
|
||||
* mention of the dsh-TUI row anywhere in it.
|
||||
*/
|
||||
function isUnrelatedItem(item: Line[]): boolean {
|
||||
const indent = item[0].indent;
|
||||
const top = item.filter((l) => l.indent === indent);
|
||||
if (top.some((l) => /^insert\s*:/.test(l.text))) return false;
|
||||
const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id');
|
||||
if (ids.length !== 1 || ids[0]![2] === undefined) return false;
|
||||
try {
|
||||
return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */
|
||||
function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } {
|
||||
if (entry.inline !== undefined) {
|
||||
if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines');
|
||||
const v = entry.inline.trim();
|
||||
// An empty flow mapping or a null names no route; anything else inline (a tag, a
|
||||
// non-empty flow mapping, a block scalar) is beyond this reader.
|
||||
if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {};
|
||||
throw new Ambiguous('inline config');
|
||||
}
|
||||
if (!entry.nested.length) return {};
|
||||
const keys = mappingKeys(entry.nested, entry.nested[0].indent);
|
||||
const out: { provider?: string; model?: string } = {};
|
||||
for (const field of ['provider', 'model'] as const) {
|
||||
const value = keys.get(field);
|
||||
if (!value) continue;
|
||||
if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`);
|
||||
out[field] = stringScalar(value.inline);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a cordis patch-list file (a top-level YAML array of loader patches) does to the
|
||||
* dsh-TUI row, in order. An empty list when the file does not touch it. Null when the
|
||||
* file is beyond this reader's subset, or when it could re-insert the row.
|
||||
*
|
||||
* @param text the file's content
|
||||
*/
|
||||
export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null {
|
||||
try {
|
||||
const lines: Line[] = [];
|
||||
let sawContent = false;
|
||||
for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
||||
const stripped = stripComment(rawLine);
|
||||
if (stripped.trim() === '') continue;
|
||||
const indent = indentOf(stripped);
|
||||
const body = stripped.slice(indent);
|
||||
if (indent === 0 && body === '---') {
|
||||
if (sawContent) throw new Ambiguous('a second document');
|
||||
continue;
|
||||
}
|
||||
sawContent = true;
|
||||
lines.push({ indent, text: body });
|
||||
}
|
||||
if (lines.length === 0) return [];
|
||||
if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return [];
|
||||
|
||||
// Split the top-level block sequence into items.
|
||||
const items: Line[][] = [];
|
||||
for (const line of lines) {
|
||||
if (line.indent === 0) {
|
||||
const m = /^-(?:(\s+)(.*))?$/.exec(line.text);
|
||||
if (!m) throw new Ambiguous('not a top-level sequence');
|
||||
const item: Line[] = [];
|
||||
// `- key: value`: the key sits at its real column, which its siblings below share.
|
||||
if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] });
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
if (items.length === 0) throw new Ambiguous('indented content before the first item');
|
||||
items[items.length - 1].push(line);
|
||||
}
|
||||
|
||||
const patches: DshTuiRowPatch[] = [];
|
||||
for (const item of items) {
|
||||
if (item.length === 0) throw new Ambiguous('empty item');
|
||||
// The first key's column is the item's indent; every key shares it.
|
||||
let keys: ReturnType<typeof mappingKeys>;
|
||||
try {
|
||||
keys = mappingKeys(item, item[0].indent);
|
||||
} catch (err) {
|
||||
// An item this reader cannot follow is harmless only when it provably is about
|
||||
// another row: its own id names one, and no line in it names the dsh-TUI row.
|
||||
if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err;
|
||||
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||
continue;
|
||||
}
|
||||
const id = keys.get('id');
|
||||
if (keys.has('insert')) {
|
||||
// An insert that could bring a second dsh-TUI row is beyond this reader.
|
||||
const body = item.map((l) => l.text).join('\n');
|
||||
if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row');
|
||||
continue;
|
||||
}
|
||||
if (!id) continue; // dsh warns and skips a non-insert patch without an id
|
||||
if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar');
|
||||
if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) {
|
||||
// Another row; but a group row's config is a list of rows, and one of them could
|
||||
// be a second dsh-TUI row (dsh indexes nested group entries by id too).
|
||||
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||
continue;
|
||||
}
|
||||
const patch: DshTuiRowPatch = {};
|
||||
const name = keys.get('name');
|
||||
if (name) {
|
||||
if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar');
|
||||
patch.name = stringScalar(name.inline);
|
||||
}
|
||||
const disabled = keys.get('disabled');
|
||||
if (disabled) {
|
||||
if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar');
|
||||
patch.disabled = boolScalar(disabled.inline);
|
||||
}
|
||||
const config = keys.get('config');
|
||||
if (config) patch.config = configOf(config);
|
||||
patches.push(patch);
|
||||
}
|
||||
return patches;
|
||||
} catch (err) {
|
||||
if (err instanceof Ambiguous) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model dsh-TUI's route config pins, given what the user layers do to its row in
|
||||
* application order (profile layer first, then the home layer). Null unless the last
|
||||
* `config` that applies names both a provider and a model, and the row is not
|
||||
* disabled. Pure.
|
||||
*
|
||||
* @param layers each layer's patches for the row, or null for a layer that could not be read
|
||||
*/
|
||||
export function resolveDshTuiRouteModel(layers: Array<DshTuiRowPatch[] | null>): string | null {
|
||||
let config: { provider?: string; model?: string } | undefined;
|
||||
let disabled = false;
|
||||
for (const layer of layers) {
|
||||
if (layer === null) return null;
|
||||
for (const patch of layer) {
|
||||
if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue;
|
||||
if (patch.disabled !== undefined) disabled = patch.disabled;
|
||||
if (patch.config !== undefined) config = patch.config;
|
||||
}
|
||||
}
|
||||
if (disabled || !config?.provider || !config.model) return null;
|
||||
return config.model;
|
||||
}
|
||||
|
||||
/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */
|
||||
type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' };
|
||||
|
||||
/**
|
||||
* Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount
|
||||
* that does not answer is refused, never waited on), its real path checked against
|
||||
* the dsh home (a symlink out of it is refused), size-capped.
|
||||
*/
|
||||
async function readHomeFile(path: string, realHome: string): Promise<FileRead> {
|
||||
const kind = await probePathKind(path);
|
||||
if (kind === 'absent') return { state: 'absent' };
|
||||
if (kind !== 'file') return { state: 'refused' };
|
||||
try {
|
||||
const real = await fs.realpath(path);
|
||||
if (!real.startsWith(realHome + sep)) return { state: 'refused' };
|
||||
const stat = await fs.stat(real);
|
||||
if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' };
|
||||
return { state: 'read', text: await fs.readFile(real, 'utf8') };
|
||||
} catch {
|
||||
return { state: 'refused' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dsh home a session runs against: its own `DSH_HOME` (already clamped for a
|
||||
* non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's
|
||||
* `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place.
|
||||
*/
|
||||
export function effectiveDshHome(env: (key: string) => string | undefined): string | null {
|
||||
const value = env('DSH_HOME')?.trim();
|
||||
if (!value) return join(homedir(), '.dsh');
|
||||
return isAbsolute(value) ? resolve(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profiles under a dsh home, read with the same bounded rules as the route files.
|
||||
* Used to name the profile a session boots when it named none, the way the launch's
|
||||
* `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile).
|
||||
*/
|
||||
async function listProfilesBounded(home: string): Promise<DeepSeekProfile[] | null> {
|
||||
const profilesDir = join(home, 'profiles');
|
||||
if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null;
|
||||
let realHome: string;
|
||||
let names: string[];
|
||||
try {
|
||||
realHome = await fs.realpath(home);
|
||||
names = (await fs.readdir(profilesDir, { withFileTypes: true }))
|
||||
.filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name))
|
||||
.map((e) => e.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.slice(0, MAX_PROFILES);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const profiles: DeepSeekProfile[] = [];
|
||||
for (const name of names) {
|
||||
const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome);
|
||||
if (manifest.state !== 'read') continue;
|
||||
const profile = deepSeekProfileFromManifest(name, manifest.text);
|
||||
if (profile) profiles.push(profile);
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/** What the reader needs to know about one session. */
|
||||
export interface DeepSeekRouteContext {
|
||||
/** The session's `deepSeekConfig.profile`, if any. */
|
||||
profile?: unknown;
|
||||
/** The session's dsh home (see {@link effectiveDshHome}). */
|
||||
home: string | null;
|
||||
/** The server's own dsh home, which names the default profile (as the launch does). */
|
||||
serverHome: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model the session's dsh-TUI route config pins, or null when it pins none or the
|
||||
* answer is in any doubt. Read-only and bounded; see the module comment.
|
||||
*/
|
||||
export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise<string | null> {
|
||||
const { home } = ctx;
|
||||
if (!home) return null;
|
||||
// An invalid name reads as unset at launch, so the default applies there too.
|
||||
let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null;
|
||||
if (!profile) {
|
||||
if (!ctx.serverHome) return null;
|
||||
const listed = await listProfilesBounded(ctx.serverHome);
|
||||
profile = listed ? resolveDefaultDeepSeekProfile(listed) : null;
|
||||
if (!profile) return null;
|
||||
}
|
||||
if ((await probePathKind(home)) !== 'directory') return null;
|
||||
let realHome: string;
|
||||
try {
|
||||
realHome = await fs.realpath(home);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const profileDir = join(home, 'profiles', profile);
|
||||
const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome);
|
||||
if (manifest.state !== 'read') return null;
|
||||
if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null;
|
||||
|
||||
const layers: Array<DshTuiRowPatch[] | null> = [];
|
||||
for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) {
|
||||
const read = await readHomeFile(file, realHome);
|
||||
if (read.state === 'refused') return null;
|
||||
layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text));
|
||||
}
|
||||
return resolveDshTuiRouteModel(layers);
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
/**
|
||||
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
|
||||
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
|
||||
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
|
||||
* visible without leaving Codeman.
|
||||
*
|
||||
* Split so the parts that matter test without a repo:
|
||||
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
|
||||
* `parseCommitLog`
|
||||
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
|
||||
* single-flight cache so several tabs polling one repo cost one set of git processes
|
||||
*
|
||||
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
|
||||
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
|
||||
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||
* to the outer one, and is not scanned;
|
||||
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
|
||||
* symlinks), each reported separately;
|
||||
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
||||
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
|
||||
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
|
||||
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
|
||||
* refreshing the index, so polling cannot contend with the agent's own git commands.
|
||||
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
|
||||
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
|
||||
* revision range.
|
||||
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
|
||||
* - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
|
||||
* during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
|
||||
* runs as this same OS user, so polling adds no privilege there. What is turned off: the
|
||||
* filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
|
||||
* program (`log.showSignature`). A repository a container can write to is NOT inspected: a
|
||||
* Docker session answers `unsupported`, and any repository whose root is, or is inside, a Docker
|
||||
* case workspace is dropped from the walk-up, the scan below a folder, and the diff route, because
|
||||
* the container could have planted that config and git here would run it on the host.
|
||||
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
|
||||
* through `redactGitCredentials`.
|
||||
*
|
||||
* @module git-workspace-status
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
/** Max file rows returned. The counts stay exact. */
|
||||
export const MAX_FILES = 300;
|
||||
/** Max unpushed commits listed. The count stays exact. */
|
||||
export const MAX_COMMITS = 50;
|
||||
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
|
||||
const CACHE_TTL_MS = 4000;
|
||||
const CACHE_MAX_ENTRIES = 64;
|
||||
|
||||
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
|
||||
|
||||
export interface GitFileEntry {
|
||||
/** Path relative to the repository root, as git reports it. */
|
||||
path: string;
|
||||
/** Rename/copy source, when the entry is one. */
|
||||
origPath?: string;
|
||||
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
|
||||
index: string;
|
||||
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
|
||||
worktree: string;
|
||||
kind: GitFileKind;
|
||||
}
|
||||
|
||||
export interface GitCommitEntry {
|
||||
hash: string;
|
||||
author: string;
|
||||
/** Seconds since the epoch. */
|
||||
time: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface GitWorkspaceStatus {
|
||||
/**
|
||||
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
|
||||
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
|
||||
*/
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
error?: string;
|
||||
repoRoot?: string;
|
||||
/** Null when HEAD is detached. */
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
upstream: string | null;
|
||||
/**
|
||||
* The configured upstream does not exist on the remote (deleted and pruned, or never pushed, as after
|
||||
* cloning an empty repository and committing): nothing is tracked.
|
||||
*/
|
||||
upstreamGone: boolean;
|
||||
ahead: number;
|
||||
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
|
||||
behind: number;
|
||||
/** Whether the repository has any remote at all. */
|
||||
hasRemote: boolean;
|
||||
counts: {
|
||||
staged: number;
|
||||
unstaged: number;
|
||||
untracked: number;
|
||||
conflicted: number;
|
||||
/** Distinct paths that are not committed. */
|
||||
uncommitted: number;
|
||||
stashes: number;
|
||||
};
|
||||
files: GitFileEntry[];
|
||||
filesTruncated: boolean;
|
||||
/** Commits on this branch that no remote has: exact. */
|
||||
unpushedCount: number;
|
||||
unpushed: GitCommitEntry[];
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
|
||||
branch: null,
|
||||
detached: false,
|
||||
upstream: null,
|
||||
upstreamGone: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
|
||||
files: [],
|
||||
filesTruncated: false,
|
||||
unpushedCount: 0,
|
||||
unpushed: [],
|
||||
};
|
||||
|
||||
export const emptyStatus = (
|
||||
state: GitWorkspaceStatus['state'],
|
||||
extra: Partial<GitWorkspaceStatus> = {}
|
||||
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedStatus {
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
upstream: string | null;
|
||||
/** `# branch.upstream` was printed but `# branch.ab` was not: no such remote branch (deleted and pruned, or never pushed). */
|
||||
upstreamGone: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitFileEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
|
||||
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
|
||||
* one more NUL-terminated token holding the original path.
|
||||
*/
|
||||
export function parsePorcelainV2(text: string): ParsedStatus {
|
||||
const out: ParsedStatus = {
|
||||
branch: null,
|
||||
detached: false,
|
||||
upstream: null,
|
||||
upstreamGone: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
files: [],
|
||||
};
|
||||
let sawAb = false;
|
||||
const tokens = text.split('\0');
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (!t) continue;
|
||||
if (t.startsWith('# ')) {
|
||||
const [key, ...rest] = t.slice(2).split(' ');
|
||||
const value = rest.join(' ');
|
||||
if (key === 'branch.head') {
|
||||
out.detached = value === '(detached)';
|
||||
out.branch = out.detached ? null : value;
|
||||
} else if (key === 'branch.upstream') {
|
||||
out.upstream = value;
|
||||
} else if (key === 'branch.ab') {
|
||||
sawAb = true;
|
||||
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
||||
if (m) {
|
||||
out.ahead = Number(m[1]);
|
||||
out.behind = Number(m[2]);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const type = t[0];
|
||||
if (type === '1') {
|
||||
// 1 XY sub mH mI mW hH hI path
|
||||
const f = t.split(' ');
|
||||
const xy = f[1] ?? '..';
|
||||
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
|
||||
} else if (type === '2') {
|
||||
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
|
||||
const f = t.split(' ');
|
||||
const xy = f[1] ?? '..';
|
||||
const path = f.slice(9).join(' ');
|
||||
const origPath = tokens[++i] ?? '';
|
||||
out.files.push(...entriesFor(xy, path, origPath));
|
||||
} else if (type === 'u') {
|
||||
// u XY sub m1 m2 m3 mW h1 h2 h3 path
|
||||
const f = t.split(' ');
|
||||
out.files.push({
|
||||
path: f.slice(10).join(' '),
|
||||
index: f[1]?.[0] ?? 'U',
|
||||
worktree: f[1]?.[1] ?? 'U',
|
||||
kind: 'conflicted',
|
||||
});
|
||||
} else if (type === '?') {
|
||||
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
|
||||
}
|
||||
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
|
||||
}
|
||||
out.upstreamGone = out.upstream !== null && !sawAb;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
|
||||
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
|
||||
const index = xy[0] ?? '.';
|
||||
const worktree = xy[1] ?? '.';
|
||||
const rows: GitFileEntry[] = [];
|
||||
const base = origPath ? { path, origPath } : { path };
|
||||
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
|
||||
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
|
||||
export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||
const out: GitCommitEntry[] = [];
|
||||
for (const record of text.split('\x1e')) {
|
||||
const r = record.replace(/^\n+/, '');
|
||||
if (!r) continue;
|
||||
const [hash, author, time, ...subject] = r.split('\x1f');
|
||||
if (!hash) continue;
|
||||
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
||||
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
|
||||
|
||||
export const runGit: GitRunner = async (cwd, args) => {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||
// consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
|
||||
// a configured gpg.program to verify signatures.
|
||||
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||
{
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||
}
|
||||
);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
||||
const e = err as { code?: unknown; stderr?: unknown; message?: string };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
|
||||
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
|
||||
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
|
||||
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
|
||||
return { notARepo: false, message: 'git timed out' };
|
||||
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
|
||||
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||
}
|
||||
|
||||
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
|
||||
let statusText: string;
|
||||
try {
|
||||
statusText = await git(cwd, [
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'-z',
|
||||
'--untracked-files=normal',
|
||||
'--ignore-submodules=dirty',
|
||||
]);
|
||||
} catch (err) {
|
||||
const f = describeFailure(err);
|
||||
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||
}
|
||||
const parsed = parsePorcelainV2(statusText);
|
||||
|
||||
const safe = async (args: string[]): Promise<string> => {
|
||||
try {
|
||||
return await git(cwd, args);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
|
||||
// resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
|
||||
const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
|
||||
// With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
|
||||
// upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
|
||||
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
|
||||
const [root, remotes, stash, countText, logText] = await Promise.all([
|
||||
safe(['rev-parse', '--show-toplevel']),
|
||||
safe(['remote']),
|
||||
safe(['stash', 'list', '--format=%gd']),
|
||||
safe(['rev-list', '--count', ...range]),
|
||||
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
|
||||
]);
|
||||
|
||||
const hasRemote = remotes.trim().length > 0;
|
||||
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
|
||||
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
|
||||
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
|
||||
|
||||
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
|
||||
const distinct = new Set<string>();
|
||||
for (const f of parsed.files) {
|
||||
counts[f.kind]++;
|
||||
distinct.add(f.path);
|
||||
}
|
||||
counts.uncommitted = distinct.size;
|
||||
counts.stashes = stash.split('\n').filter(Boolean).length;
|
||||
|
||||
return {
|
||||
state: 'ok',
|
||||
repoRoot: root.trim() || undefined,
|
||||
branch: parsed.branch,
|
||||
detached: parsed.detached,
|
||||
upstream: parsed.upstream,
|
||||
upstreamGone: parsed.upstreamGone,
|
||||
ahead: parsed.ahead,
|
||||
behind: parsed.behind,
|
||||
hasRemote,
|
||||
counts,
|
||||
files: parsed.files.slice(0, MAX_FILES),
|
||||
filesTruncated: parsed.files.length > MAX_FILES,
|
||||
unpushedCount,
|
||||
unpushed,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
at: number;
|
||||
value?: T;
|
||||
inflight?: Promise<T>;
|
||||
}
|
||||
const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
|
||||
|
||||
/** For tests. */
|
||||
export function clearGitStatusCache(): void {
|
||||
cache.clear();
|
||||
toplevelCache.clear();
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
|
||||
* flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
|
||||
* Refresh and expects the truth) but still joins a computation that is already running, which is as
|
||||
* current as a new one would be.
|
||||
*/
|
||||
async function singleFlight<T>(
|
||||
map: Map<string, CacheEntry<T>>,
|
||||
key: string,
|
||||
opts: { now: () => number; fresh?: boolean },
|
||||
compute: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const hit = map.get(key);
|
||||
if (hit?.inflight) return hit.inflight;
|
||||
if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||
|
||||
const inflight = compute();
|
||||
map.set(key, { at: opts.now(), inflight });
|
||||
try {
|
||||
const value = await inflight;
|
||||
map.set(key, { at: opts.now(), value });
|
||||
if (map.size > CACHE_MAX_ENTRIES) {
|
||||
for (const [k, v] of map) {
|
||||
if (map.size <= CACHE_MAX_ENTRIES) break;
|
||||
if (k !== key && !v.inflight) map.delete(k);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
map.delete(key);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
|
||||
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
|
||||
* `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
|
||||
*/
|
||||
export async function getGitWorkspaceStatus(
|
||||
cwd: string,
|
||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
||||
): Promise<GitWorkspaceStatus> {
|
||||
const git = opts.git ?? runGit;
|
||||
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
|
||||
}
|
||||
|
||||
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
||||
const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
|
||||
|
||||
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
||||
function enclosingRepoRoot(
|
||||
cwd: string,
|
||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
|
||||
): Promise<RepoToplevel> {
|
||||
const git = opts.git ?? runGit;
|
||||
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||
try {
|
||||
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
|
||||
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||
} catch (err) {
|
||||
const f = describeFailure(err);
|
||||
return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Which repositories: the overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
|
||||
const DISCOVERY_MAX_DEPTH = 2;
|
||||
/** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
|
||||
const DISCOVERY_MAX_ENTRIES = 300;
|
||||
/** Repositories reported for one workspace. */
|
||||
export const MAX_REPOS = 12;
|
||||
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
||||
const DISCOVERY_TTL_MS = 30_000;
|
||||
/** Folders that are never worth descending into when looking for projects. */
|
||||
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
|
||||
/** Status calls in flight at once for one overview: each is several git processes. */
|
||||
const STATUS_CONCURRENCY = 4;
|
||||
|
||||
export interface GitRepoEntry {
|
||||
/** Folder name of the repository (its root's basename). */
|
||||
name: string;
|
||||
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
|
||||
path: string;
|
||||
status: GitWorkspaceStatus;
|
||||
}
|
||||
|
||||
export interface GitWorkspaceOverview {
|
||||
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
error?: string;
|
||||
repos: GitRepoEntry[];
|
||||
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
|
||||
reposTruncated: boolean;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export const emptyOverview = (
|
||||
state: GitWorkspaceOverview['state'],
|
||||
extra: Partial<GitWorkspaceOverview> = {}
|
||||
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
|
||||
|
||||
const realOr = async (p: string): Promise<string> => {
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
/** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
|
||||
const realAll = (dirs: string[]): Promise<string[]> => Promise.all(dirs.map(realOr));
|
||||
|
||||
const isWithin = (child: string, root: string): boolean => child === root || child.startsWith(root + sep);
|
||||
|
||||
/**
|
||||
* True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
|
||||
* workspaces: a container can write there, so git must not run on its behalf on the host.
|
||||
*/
|
||||
export async function isInsideAny(path: string, realRoots: string[]): Promise<boolean> {
|
||||
if (!realRoots.length) return false;
|
||||
const real = await realOr(path);
|
||||
return realRoots.some((r) => isWithin(real, r));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
|
||||
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
|
||||
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
|
||||
*/
|
||||
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
|
||||
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
|
||||
if (root === here) return false;
|
||||
return root === sep || h === root || h.startsWith(root + sep);
|
||||
}
|
||||
|
||||
async function hasDotGit(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
|
||||
const DISCOVERY_MAX_SCAN = 5000;
|
||||
|
||||
/** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
|
||||
async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] | null> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.opendir(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const out: import('node:fs').Dirent[] = [];
|
||||
try {
|
||||
for await (const e of handle) {
|
||||
out.push(e);
|
||||
if (out.length >= DISCOVERY_MAX_SCAN) break;
|
||||
}
|
||||
} catch {
|
||||
/* a folder that fails mid-read: use what was read */
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||
export async function discoverChildRepos(
|
||||
cwd: string,
|
||||
excludeRealRoots: string[] = []
|
||||
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||
const found: string[] = [];
|
||||
let level = [cwd];
|
||||
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
|
||||
const next: string[] = [];
|
||||
for (const dir of level) {
|
||||
const entries = await readDirBounded(dir);
|
||||
if (!entries) continue;
|
||||
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
|
||||
for (const e of entries) {
|
||||
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
|
||||
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
|
||||
const child = join(dir, e.name);
|
||||
// A Docker case workspace (or anything inside one) is never inspected, nor descended into.
|
||||
if (await isInsideAny(child, excludeRealRoots)) continue;
|
||||
if (await hasDotGit(child)) found.push(child);
|
||||
else next.push(child);
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
|
||||
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface GitOverviewOptions {
|
||||
git?: GitRunner;
|
||||
now?: () => number;
|
||||
fresh?: boolean;
|
||||
home?: string;
|
||||
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||
dockerWorkspaces?: string[];
|
||||
}
|
||||
|
||||
type WorkspaceRepos =
|
||||
| { kind: 'docker' }
|
||||
| { kind: 'error'; error: string }
|
||||
| { kind: 'enclosing'; root: string }
|
||||
| { kind: 'children'; dirs: string[]; truncated: boolean };
|
||||
|
||||
/**
|
||||
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
|
||||
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
|
||||
*/
|
||||
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||
const now = opts.now ?? Date.now;
|
||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
||||
// could carry config (a clean filter) that runs on the host.
|
||||
if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
|
||||
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
||||
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
||||
// repositories below.
|
||||
const top = await enclosingRepoRoot(cwd, opts);
|
||||
if (top.state === 'error') return { kind: 'error', error: top.error };
|
||||
if (top.state === 'ok') {
|
||||
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
||||
if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
|
||||
return { kind: 'enclosing', root: top.root };
|
||||
}
|
||||
|
||||
// Not inside a repository of this workspace: look below for projects.
|
||||
const hit = discoveryCache.get(cwd);
|
||||
let found: { dirs: string[]; truncated: boolean };
|
||||
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||
else {
|
||||
found = await discoverChildRepos(cwd, dockerRoots);
|
||||
discoveryCache.set(cwd, { at: now(), value: found });
|
||||
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
||||
}
|
||||
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
||||
const dirs: string[] = [];
|
||||
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
||||
return { kind: 'children', dirs, truncated: found.truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything git knows about the session's workspace: the enclosing repository when there is one,
|
||||
* otherwise each repository found below the working directory. See the module header for the rules.
|
||||
*/
|
||||
export async function getGitWorkspaceOverview(
|
||||
cwd: string,
|
||||
opts: GitOverviewOptions = {}
|
||||
): Promise<GitWorkspaceOverview> {
|
||||
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||
if (where.kind === 'enclosing') {
|
||||
const primary = await getGitWorkspaceStatus(cwd, opts);
|
||||
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||
const root = primary.repoRoot ?? where.root;
|
||||
return {
|
||||
state: 'ok',
|
||||
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
|
||||
reposTruncated: false,
|
||||
checkedAt: primary.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
|
||||
const repos: GitRepoEntry[] = [];
|
||||
where.dirs.forEach((dir, i) => {
|
||||
const status = statuses[i];
|
||||
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
||||
});
|
||||
if (!repos.length) return emptyOverview('not-a-repo');
|
||||
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
|
||||
* and caches, and the Docker roots as they are now), else null. The diff route checks a requested
|
||||
* repository with this rather than recomputing every repository's status.
|
||||
*/
|
||||
export async function findWorkspaceRepo(
|
||||
cwd: string,
|
||||
repo: string,
|
||||
opts: GitOverviewOptions = {}
|
||||
): Promise<string | null> {
|
||||
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||
const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
|
||||
// git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
|
||||
for (const root of roots) if (root === repo || (await realOr(root)) === repo) return repo;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Per-file diff ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
|
||||
export const MAX_DIFF_BYTES = 400 * 1024;
|
||||
|
||||
export interface GitFileDiff {
|
||||
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
|
||||
diff: string;
|
||||
truncated: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
/** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
|
||||
export function isSafeRepoRelativePath(p: string): boolean {
|
||||
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/')) return false;
|
||||
return !p.split('/').includes('..');
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
|
||||
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
|
||||
* `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
|
||||
* diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
|
||||
* `git diff`, which is why a container-writable repository never reaches this function.
|
||||
*/
|
||||
export async function getGitFileDiff(
|
||||
repoRoot: string,
|
||||
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||
opts: { git?: GitRunner } = {}
|
||||
): Promise<GitFileDiff> {
|
||||
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||
throw new Error('Invalid path');
|
||||
}
|
||||
const git = opts.git ?? runGit;
|
||||
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
|
||||
let args: string[];
|
||||
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
|
||||
else {
|
||||
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
|
||||
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
|
||||
}
|
||||
let out: string;
|
||||
let cutShort = false;
|
||||
try {
|
||||
out = await git(repoRoot, args);
|
||||
} catch (err) {
|
||||
const e = err as { code?: unknown; stdout?: unknown };
|
||||
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
|
||||
// A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
|
||||
// any oversized diff.
|
||||
else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
|
||||
out = e.stdout;
|
||||
cutShort = true;
|
||||
} else throw err;
|
||||
}
|
||||
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
|
||||
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: cutShort, binary };
|
||||
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
|
||||
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
|
||||
}
|
||||
+14
-85
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
@@ -39,52 +40,6 @@ import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||
* which gives up after a timeout and answers "unknown", this waits for the real
|
||||
* answer: only ENOENT reads as absent, anything else throws, so a
|
||||
* stalled or unreadable workspace can never be mistaken for an empty one and
|
||||
* have its settings recreated over the top. It is async, so a dead mount ties
|
||||
* up a threadpool worker rather than the event loop.
|
||||
*/
|
||||
async function pathExistsForWrite(path: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
|
||||
* stalled probe (the bulk cap refused it, or the stat failed with something other
|
||||
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
|
||||
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
|
||||
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
|
||||
* on a dead mount those never settle, and each would hold a threadpool worker the
|
||||
* probe's ceiling does not count.
|
||||
*/
|
||||
async function probeBeforeTouching(path: string) {
|
||||
const state = await probePath(path);
|
||||
if (state !== 'unknown' || isNearStalledPath(path)) return state;
|
||||
return probePath(path, { pastCap: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
|
||||
* it did not answer (a mount that is not responding, a refused probe, an unreadable
|
||||
* path). See `probeBeforeTouching` for why "unknown" is a skip.
|
||||
*/
|
||||
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
|
||||
const state = await probeBeforeTouching(path);
|
||||
if (state === 'absent') return 'absent';
|
||||
if (state === 'unknown') return 'unreachable';
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
@@ -603,7 +558,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
if (keysToRemove.length === 0) return;
|
||||
|
||||
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
|
||||
if (!(await pathExistsForWrite(settingsPath))) return;
|
||||
if (!existsSync(settingsPath)) return;
|
||||
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
@@ -635,7 +590,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
*/
|
||||
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -666,7 +621,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
||||
*/
|
||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -695,7 +650,7 @@ export async function updateCaseModel(casePath: string, model: string | null): P
|
||||
*/
|
||||
export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -743,7 +698,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
*/
|
||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -783,7 +738,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
||||
*/
|
||||
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
||||
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
|
||||
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
|
||||
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
@@ -865,17 +820,7 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
||||
*/
|
||||
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
||||
try {
|
||||
// "absent" stays absent: the install below would mkdir -p a deleted repo back
|
||||
// into being. "unknown" is skipped too, never asked again with an unbounded
|
||||
// lstat (see probeBeforeTouching).
|
||||
const state = await probeBeforeTouching(workspace);
|
||||
if (state === 'absent') return;
|
||||
if (state === 'unknown') {
|
||||
console.warn(
|
||||
`[hooks] ${workspace} is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!existsSync(workspace)) return;
|
||||
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
|
||||
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
||||
} catch {
|
||||
@@ -938,7 +883,7 @@ export function generateStatusLineCommand(): string {
|
||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (await pathExistsForWrite(settingsPath)) {
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
@@ -953,7 +898,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
const desired = generateStatusLineCommand();
|
||||
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
||||
if (!(await pathExistsForWrite(claudeDir))) await mkdir(claudeDir, { recursive: true });
|
||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
||||
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
||||
} else {
|
||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||
@@ -1012,7 +957,7 @@ function statusLineExporterScriptContent(): string {
|
||||
}
|
||||
|
||||
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
|
||||
if (await absentOrUnreachable(settingsPath)) return undefined;
|
||||
if (!existsSync(settingsPath)) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
const current = parsed.statusLine as { command?: unknown } | undefined;
|
||||
@@ -1086,11 +1031,7 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
||||
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
||||
// windows in which Claude Code could run an empty or non-executable file.
|
||||
// rename() swaps the complete, already-executable file in atomically.
|
||||
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
|
||||
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
|
||||
// name let the first rename consume the others' temp file, and their ENOENT
|
||||
// dropped those sessions from tmux to the direct-PTY fallback.
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmpPath, desired);
|
||||
await chmod(tmpPath, 0o755);
|
||||
await rename(tmpPath, scriptPath);
|
||||
@@ -1158,21 +1099,9 @@ export async function resolveStatusLineCliCommand(
|
||||
): Promise<string | undefined> {
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
let userHasOwnStatusLine = false;
|
||||
const skip = await absentOrUnreachable(settingsPath);
|
||||
// Unreachable: whether the user configured their own statusLine there cannot be
|
||||
// told, and this must never override a real one, so inject nothing.
|
||||
if (skip === 'unreachable') return undefined;
|
||||
if (!skip) {
|
||||
let raw: string;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
raw = await readFile(settingsPath, 'utf-8');
|
||||
} catch (err) {
|
||||
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
|
||||
raw = '';
|
||||
}
|
||||
try {
|
||||
const existing = raw ? JSON.parse(raw) : {};
|
||||
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||
if (current && typeof current.command === 'string') {
|
||||
if (current.command.includes(STATUSLINE_MARKER)) {
|
||||
|
||||
-679
@@ -1,679 +0,0 @@
|
||||
/**
|
||||
* @fileoverview MCP server sync between the enabled agent CLIs.
|
||||
*
|
||||
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
|
||||
* names the file and the dialect). This module reads every participating CLI's list into one
|
||||
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
|
||||
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
|
||||
* own user config.
|
||||
*
|
||||
* Deliberately conservative:
|
||||
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
|
||||
* does not understand) is never rewritten and nothing is ever removed. Same name with a
|
||||
* different definition is reported as a conflict and left alone.
|
||||
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
|
||||
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
|
||||
* switch it on in every other CLI.
|
||||
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
|
||||
* duplicate table) is never written, and a write is only made after the NEW text has been
|
||||
* parsed again and every added server comes back as intended.
|
||||
* - Only the MCP table is touched; every other key in the file is preserved. Files are
|
||||
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
|
||||
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
|
||||
* (overwritten by each sync).
|
||||
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
|
||||
* readable by its owner only.
|
||||
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
|
||||
* - Only one apply runs at a time.
|
||||
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
|
||||
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
|
||||
* cannot be located safely, so that target is reported `skipped` and never written.
|
||||
*
|
||||
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
|
||||
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
|
||||
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
|
||||
*
|
||||
* @module mcp-sync
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
import { parse as parseToml, TomlError } from 'smol-toml';
|
||||
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
|
||||
|
||||
export type McpFormat = McpConfigFormat;
|
||||
|
||||
export interface McpServer {
|
||||
transport: 'stdio' | 'http' | 'sse';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/** Switched off in the CLI that defines it. Never propagated. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type McpServerMap = Record<string, McpServer>;
|
||||
|
||||
export interface McpSyncTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Home-relative default location of the config file. */
|
||||
path: string;
|
||||
format: McpFormat;
|
||||
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
|
||||
relocation?: { envVar: string; path: string };
|
||||
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
/** A second apply was requested while one was running. */
|
||||
export class McpSyncBusyError extends Error {
|
||||
constructor() {
|
||||
super('An MCP sync is already running');
|
||||
this.name = 'McpSyncBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error whose message this module wrote itself. It names keys Codeman chose and server names
|
||||
* (which the result reports anyway), never a value from the file, so it may be shown as is.
|
||||
*/
|
||||
class McpConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'McpConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
|
||||
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
|
||||
* "Unexpected token" errors quote about ten characters of source. These files hold env values
|
||||
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
|
||||
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
|
||||
* else by a fixed category.
|
||||
*/
|
||||
function describeMcpSyncError(err: unknown): string {
|
||||
if (err instanceof McpConfigError) return err.message;
|
||||
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
|
||||
if (err instanceof SyntaxError) {
|
||||
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
|
||||
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
|
||||
const pos = /at position (\d+)/.exec(err.message);
|
||||
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
|
||||
}
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
|
||||
return 'unexpected error';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
|
||||
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
|
||||
function dict<T>(): Record<string, T> {
|
||||
return Object.create(null) as Record<string, T>;
|
||||
}
|
||||
|
||||
/** Own, safe keys of an untrusted table. */
|
||||
function safeKeys(table: Record<string, unknown>): string[] {
|
||||
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
|
||||
}
|
||||
|
||||
function strMap(v: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(v)) return undefined;
|
||||
const out = dict<string>();
|
||||
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
function strArr(v: unknown): string[] | undefined {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
|
||||
}
|
||||
|
||||
/** Drop undefined/empty fields so equal servers compare equal. */
|
||||
function clean(s: McpServer): McpServer {
|
||||
const out: McpServer = { transport: s.transport };
|
||||
if (s.command) out.command = s.command;
|
||||
if (s.args?.length) out.args = s.args;
|
||||
if (s.env && Object.keys(s.env).length) out.env = s.env;
|
||||
if (s.cwd) out.cwd = s.cwd;
|
||||
if (s.url) out.url = s.url;
|
||||
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
|
||||
if (s.disabled) out.disabled = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
|
||||
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
|
||||
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
|
||||
function fingerprint(s: McpServer): string {
|
||||
const t = s.transport === 'stdio' ? 'stdio' : 'url';
|
||||
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
|
||||
}
|
||||
|
||||
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
|
||||
function fullIdentity(s: McpServer): string {
|
||||
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
|
||||
}
|
||||
|
||||
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON dialects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fromClaude(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const type = raw.type;
|
||||
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
|
||||
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toClaude(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} };
|
||||
return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
|
||||
}
|
||||
|
||||
function fromGemini(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
|
||||
// today, and a bare `url` with no type is the legacy SSE form.
|
||||
if (typeof raw.httpUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
||||
if (typeof raw.url === 'string') {
|
||||
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: raw.command,
|
||||
args: strArr(raw.args),
|
||||
env: strMap(raw.env),
|
||||
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGemini(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') {
|
||||
return {
|
||||
command: s.command,
|
||||
args: s.args ?? [],
|
||||
...(s.env ? { env: s.env } : {}),
|
||||
...(s.cwd ? { cwd: s.cwd } : {}),
|
||||
};
|
||||
}
|
||||
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
|
||||
}
|
||||
|
||||
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
|
||||
function fromAntigravity(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.disabled === true;
|
||||
if (typeof raw.serverUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: raw.command,
|
||||
args: strArr(raw.args),
|
||||
env: strMap(raw.env),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toAntigravity(s: McpServer): Record<string, unknown> | null {
|
||||
if (s.transport === 'sse') return null;
|
||||
if (s.transport === 'stdio') {
|
||||
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
|
||||
}
|
||||
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
|
||||
}
|
||||
|
||||
function fromOpencode(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.enabled === false;
|
||||
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
||||
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
||||
}
|
||||
if (raw.type === 'local') {
|
||||
const cmd = strArr(raw.command);
|
||||
if (!cmd?.length) return null;
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: cmd[0],
|
||||
args: cmd.slice(1),
|
||||
env: strMap(raw.environment),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toOpencode(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') {
|
||||
return {
|
||||
type: 'local',
|
||||
command: [s.command, ...(s.args ?? [])],
|
||||
...(s.env ? { environment: s.env } : {}),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
|
||||
}
|
||||
|
||||
interface JsonDialect {
|
||||
/** Key holding the server table. */
|
||||
key: string;
|
||||
from(raw: unknown): McpServer | null;
|
||||
to(s: McpServer): Record<string, unknown> | null;
|
||||
/** Top-level keys to seed when creating the file from nothing. */
|
||||
seed?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
||||
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
||||
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
||||
'opencode-json': {
|
||||
key: 'mcp',
|
||||
from: fromOpencode,
|
||||
to: toOpencode,
|
||||
seed: { $schema: 'https://opencode.ai/config.json' },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codex TOML (the `[mcp_servers.*]` tables only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fromCodex(t: Record<string, unknown>): McpServer | null {
|
||||
const disabled = t.enabled === false;
|
||||
if (typeof t.url === 'string') {
|
||||
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
|
||||
}
|
||||
if (typeof t.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const tomlStr = (v: string): string => JSON.stringify(v);
|
||||
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
|
||||
|
||||
function toCodexToml(name: string, s: McpServer): string {
|
||||
const head = `[mcp_servers.${tomlKey(name)}]`;
|
||||
const lines = [head];
|
||||
if (s.transport === 'stdio') {
|
||||
lines.push(`command = ${tomlStr(s.command ?? '')}`);
|
||||
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
|
||||
if (s.env) {
|
||||
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
|
||||
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`url = ${tomlStr(s.url ?? '')}`);
|
||||
if (s.headers) {
|
||||
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
|
||||
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialect entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedConfig {
|
||||
/** Servers this module understands. */
|
||||
servers: McpServerMap;
|
||||
/** Every name defined under the MCP table, in any shape: these are never appended over. */
|
||||
names: Set<string>;
|
||||
}
|
||||
|
||||
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
|
||||
if (text === null || !text.trim()) return dict<unknown>();
|
||||
if (format === 'codex-toml') {
|
||||
const doc = parseToml(text);
|
||||
const table = doc.mcp_servers;
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
|
||||
return table;
|
||||
}
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: unknown = JSON.parse(text);
|
||||
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
|
||||
const table = doc[dialect.key];
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
|
||||
const table = mcpTable(format, text);
|
||||
const servers = dict<McpServer>();
|
||||
const names = new Set<string>();
|
||||
for (const name of safeKeys(table)) {
|
||||
names.add(name);
|
||||
const raw = table[name];
|
||||
const s =
|
||||
format === 'codex-toml'
|
||||
? isRecord(raw)
|
||||
? fromCodex(raw)
|
||||
: null
|
||||
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
|
||||
if (s) servers[name] = s;
|
||||
}
|
||||
return { servers, names };
|
||||
}
|
||||
|
||||
/** The servers of a config file's text. */
|
||||
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
|
||||
return parseConfig(format, text).servers;
|
||||
}
|
||||
|
||||
/** Whether this dialect can express the server. */
|
||||
function canExpress(format: McpFormat, s: McpServer): boolean {
|
||||
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add servers to a config file's text and return the new text. A name already defined under the
|
||||
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
|
||||
* come back as intended, otherwise this throws and nothing should be written.
|
||||
*/
|
||||
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
|
||||
const before = parseConfig(format, text);
|
||||
const todo = dict<McpServer>();
|
||||
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
|
||||
const names = Object.keys(todo);
|
||||
if (names.length === 0) return text ?? '';
|
||||
|
||||
let out: string;
|
||||
if (format === 'codex-toml') {
|
||||
const base = text ?? '';
|
||||
const eol = base.includes('\r\n') ? '\r\n' : '\n';
|
||||
const sep =
|
||||
base.length === 0
|
||||
? ''
|
||||
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
|
||||
? ''
|
||||
: base.endsWith('\n')
|
||||
? eol
|
||||
: eol + eol;
|
||||
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
|
||||
out = base + sep + blocks.join(eol);
|
||||
} else {
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: Record<string, unknown> =
|
||||
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
|
||||
const existing = doc[dialect.key];
|
||||
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
|
||||
for (const n of names) {
|
||||
const entry = dialect.to(todo[n]);
|
||||
if (entry) table[n] = entry;
|
||||
}
|
||||
doc[dialect.key] = table;
|
||||
out = JSON.stringify(doc, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// Re-read what we are about to write.
|
||||
const after = parseConfig(format, out);
|
||||
for (const n of before.names) {
|
||||
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
|
||||
}
|
||||
for (const n of names) {
|
||||
const got = after.servers[n];
|
||||
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
|
||||
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function readText(file: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(file, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
|
||||
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
|
||||
* readable by its owner only.
|
||||
*/
|
||||
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
|
||||
let target = file;
|
||||
try {
|
||||
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
|
||||
try {
|
||||
await fs.lstat(file);
|
||||
throw new McpConfigError('config path is a dangling symlink');
|
||||
} catch (inner) {
|
||||
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
|
||||
}
|
||||
}
|
||||
|
||||
let mode = 0o600;
|
||||
try {
|
||||
mode = (await fs.stat(target)).mode & 0o777;
|
||||
await fs.copyFile(target, `${target}.codeman-bak`);
|
||||
await fs.chmod(`${target}.codeman-bak`, 0o600);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
if (secret) mode &= ~0o077;
|
||||
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
await fs.writeFile(tmp, text, { mode });
|
||||
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
|
||||
await fs.chmod(tmp, mode);
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmp).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpSyncOptions {
|
||||
/** false = report what would change without writing. */
|
||||
apply: boolean;
|
||||
home?: string;
|
||||
/**
|
||||
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
|
||||
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
|
||||
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
|
||||
* env can never aim a write outside that home.
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The config file a target means, honouring its relocation env var. `skip` is set when the var
|
||||
* holds something that cannot be located safely (a relative path resolves against the CLI's
|
||||
* working directory, which differs per session), so the target is neither read nor written.
|
||||
*/
|
||||
function resolveFile(
|
||||
t: McpSyncTarget,
|
||||
home: string,
|
||||
env: Record<string, string | undefined>
|
||||
): { file: string; skip?: string } {
|
||||
const rel = t.relocation;
|
||||
const dir = rel ? env[rel.envVar] : undefined;
|
||||
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
|
||||
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
|
||||
if (!isAbsolute(dir)) {
|
||||
return {
|
||||
file: `$${rel.envVar}/${rel.path}`,
|
||||
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
|
||||
};
|
||||
}
|
||||
return { file: join(dir, rel.path) };
|
||||
}
|
||||
|
||||
let applying = false;
|
||||
|
||||
/**
|
||||
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
|
||||
* order: when two CLIs define a name differently, the first one's definition is the one copied).
|
||||
* Throws `McpSyncBusyError` if another apply is running.
|
||||
*/
|
||||
export async function syncMcpServers(
|
||||
targets: McpSyncTarget[],
|
||||
opts: McpSyncOptions,
|
||||
unsupported: string[] = []
|
||||
): Promise<McpSyncResult> {
|
||||
if (opts.apply) {
|
||||
if (applying) throw new McpSyncBusyError();
|
||||
applying = true;
|
||||
}
|
||||
try {
|
||||
return await run(targets, opts, unsupported);
|
||||
} finally {
|
||||
if (opts.apply) applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
|
||||
const home = opts.home ?? homedir();
|
||||
const env = opts.env ?? (opts.home === undefined ? process.env : {});
|
||||
const seen = new Set<string>();
|
||||
const live = targets
|
||||
.map((t) => ({ t, ...resolveFile(t, home, env) }))
|
||||
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
|
||||
|
||||
const state = live.map(({ t, file, skip }) => {
|
||||
const res: McpSyncTargetResult = {
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
file,
|
||||
status: skip ? 'skipped' : 'ok',
|
||||
...(skip ? { error: skip } : {}),
|
||||
servers: [],
|
||||
added: [],
|
||||
skipped: [],
|
||||
};
|
||||
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
|
||||
});
|
||||
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
try {
|
||||
if (!s.t.installed && !(await exists(s.file))) {
|
||||
s.res.status = 'absent';
|
||||
continue;
|
||||
}
|
||||
const parsed = parseConfig(s.t.format, await readText(s.file));
|
||||
s.servers = parsed.servers;
|
||||
s.names = parsed.names;
|
||||
s.res.servers = [...parsed.names];
|
||||
} catch (err) {
|
||||
s.res.status = 'unreadable';
|
||||
s.res.error = describeMcpSyncError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
|
||||
const union = dict<McpServer>();
|
||||
const conflicts = new Set<string>();
|
||||
const switchedOff = new Set<string>();
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
for (const name of Object.keys(s.servers)) {
|
||||
const def = s.servers[name];
|
||||
if (def.disabled) {
|
||||
switchedOff.add(name);
|
||||
continue;
|
||||
}
|
||||
if (!(name in union)) union[name] = def;
|
||||
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
|
||||
}
|
||||
}
|
||||
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
|
||||
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
const add = dict<McpServer>();
|
||||
for (const name of Object.keys(union)) {
|
||||
if (s.names.has(name)) continue;
|
||||
if (canExpress(s.t.format, union[name])) add[name] = union[name];
|
||||
else s.res.skipped.push(name);
|
||||
}
|
||||
s.res.added = Object.keys(add);
|
||||
if (!opts.apply || s.res.added.length === 0) continue;
|
||||
try {
|
||||
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
|
||||
const fresh = await readText(s.file);
|
||||
const out = addServers(s.t.format, fresh, add);
|
||||
const current = parseConfig(s.t.format, fresh);
|
||||
const written = Object.keys(add).filter((n) => !current.names.has(n));
|
||||
if (written.length === 0) {
|
||||
s.res.added = [];
|
||||
continue;
|
||||
}
|
||||
const subset = dict<McpServer>();
|
||||
for (const n of written) subset[n] = add[n];
|
||||
await writeAtomic(s.file, out, carriesSecrets(subset));
|
||||
s.res.added = written;
|
||||
} catch (err) {
|
||||
s.res.status = 'failed';
|
||||
s.res.error = describeMcpSyncError(err);
|
||||
s.res.added = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applied: opts.apply,
|
||||
targets: state.map((s) => s.res),
|
||||
conflicts: [...conflicts].sort(),
|
||||
disabled,
|
||||
unsupported,
|
||||
};
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* @fileoverview The config readers a CLI's registry entry may name for the model its
|
||||
* session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives
|
||||
* here, keyed by name, so no code branches on a CLI id (like the launcher profiles in
|
||||
* config/cli-registry/profiles.ts).
|
||||
*
|
||||
* A reader answers the model the CLI's own config pins for one session, or null when
|
||||
* it pins none or the answer is in any doubt. It must be read-only, bounded (no
|
||||
* synchronous filesystem call, nothing that can wait on a dead mount) and must return
|
||||
* the model id alone, never another config value.
|
||||
*
|
||||
* @module model-config-resolvers
|
||||
*/
|
||||
|
||||
import type { ModelConfigResolverName } from './config/cli-registry/types.js';
|
||||
import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js';
|
||||
|
||||
/** What a reader gets to know about the session. */
|
||||
export interface ModelConfigContext {
|
||||
/** The session's own launch config for its CLI (its `<Mode>Config`), if any. */
|
||||
config: Record<string, unknown> | undefined;
|
||||
/** The environment the session's CLI runs with (its own overrides, then the server's). */
|
||||
env: (key: string) => string | undefined;
|
||||
}
|
||||
|
||||
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
|
||||
// dsh-TUI's route: the session's profile (else the one the launch boots, which the
|
||||
// launch names from the server's own dsh home) read under the session's dsh home.
|
||||
'deepseek-route': (ctx) =>
|
||||
readDeepSeekRouteModel({
|
||||
profile: ctx.config?.profile,
|
||||
home: effectiveDshHome(ctx.env),
|
||||
serverHome: effectiveDshHome((key) => process.env[key]),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* The model the named reader resolves for a session, or null.
|
||||
*
|
||||
* @param name a `configResolver` from the registry (schema-checked at load)
|
||||
* @param ctx what the reader may know about the session
|
||||
*/
|
||||
export async function resolveConfigModel(
|
||||
name: ModelConfigResolverName,
|
||||
ctx: ModelConfigContext
|
||||
): Promise<string | null> {
|
||||
const resolver = RESOLVERS[name];
|
||||
return resolver ? resolver(ctx) : null;
|
||||
}
|
||||
@@ -115,8 +115,6 @@ export interface CreateSessionOptions {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
|
||||
advisorModel?: string;
|
||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
@@ -166,8 +164,6 @@ export interface RespawnPaneOptions {
|
||||
unsetEnvKeys?: string[];
|
||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
|
||||
advisorModel?: string;
|
||||
/** 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 */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { ClaudeMode, EffortLevel } from './types.js';
|
||||
import { isAdvisorModel, isEffortLevel } from './types.js';
|
||||
import { isEffortLevel } from './types.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
import { compareVersions } from './utils/dependency-checker.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
@@ -54,25 +54,6 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
|
||||
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--settings` keys that switch on Claude Code's advisor tool for one session: a
|
||||
* stronger model the main model consults at decision points (code.claude.com/docs/en/advisor).
|
||||
* Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally.
|
||||
*
|
||||
* ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at
|
||||
* launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an
|
||||
* advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which
|
||||
* would leave a dead pane on every spawn and respawn. The settings key degrades instead: the
|
||||
* CLI simply does not attach an advisor it cannot use. It is a SOFT default either way:
|
||||
* `/advisor` still switches or turns it off inside the running session.
|
||||
*
|
||||
* ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into
|
||||
* the same JSON object as ultracode and the statusLine exporter, never rendered on its own.
|
||||
*/
|
||||
export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } {
|
||||
return isAdvisorModel(advisorModel) ? { advisorModel } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
|
||||
* that ships cross-session messaging (the feature that makes the peer name matter),
|
||||
@@ -130,7 +111,6 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st
|
||||
* @param effort - Optional effort level, injected via --settings (overridable in-session)
|
||||
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
|
||||
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
|
||||
* @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings)
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildInteractiveArgs(
|
||||
@@ -140,21 +120,11 @@ export function buildInteractiveArgs(
|
||||
allowedTools?: string,
|
||||
effort?: EffortLevel,
|
||||
sessionName?: string,
|
||||
cliVersion?: string | null,
|
||||
advisorModel?: string
|
||||
cliVersion?: string | null
|
||||
): string[] {
|
||||
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
||||
if (model) args.push('--model', model);
|
||||
const effortArgs = buildEffortCliArgs(effort);
|
||||
const advisor = buildAdvisorSettings(advisorModel);
|
||||
if (advisor.advisorModel === undefined) {
|
||||
args.push(...effortArgs);
|
||||
} else if (effortArgs[0] === '--settings') {
|
||||
// One --settings flag only: fold the advisor into ultracode's JSON object.
|
||||
args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor }));
|
||||
} else {
|
||||
args.push(...effortArgs, '--settings', JSON.stringify(advisor));
|
||||
}
|
||||
args.push(...buildEffortCliArgs(effort));
|
||||
args.push(...buildNameCliArgs(sessionName, cliVersion));
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
|
||||
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
|
||||
import { compareVersions } from './utils/dependency-checker.js';
|
||||
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
|
||||
import { launcherDefaultTarget } from './utils/cli-launcher.js';
|
||||
@@ -54,8 +54,6 @@ export interface SpawnBridgeOptions {
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */
|
||||
advisorModel?: string;
|
||||
sessionName?: string;
|
||||
claudeCliVersion?: string | null;
|
||||
/**
|
||||
@@ -200,20 +198,14 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
engineValues.effortLevel = effortValue;
|
||||
}
|
||||
|
||||
// Fold the advisor model and the ephemeral plan-usage statusLine exporter (see
|
||||
// resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as
|
||||
// ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation:
|
||||
// rendering them as independent params would let the last one silently win. Claude-only in
|
||||
// practice: only claude's launch template renders this engine value, so another CLI's
|
||||
// session carrying an advisorModel launches exactly as before.
|
||||
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
|
||||
// byte-identical to one from before the advisor existed.
|
||||
const advisorSettings = buildAdvisorSettings(options.advisorModel);
|
||||
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
|
||||
const settingsObj: Record<string, unknown> = {
|
||||
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
|
||||
...advisorSettings,
|
||||
};
|
||||
// Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in
|
||||
// hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude
|
||||
// Code accepts only one `--settings` flag per invocation — rendering them as two independent
|
||||
// params would let the second one silently win. Claude-only in practice (statusLineCommand
|
||||
// is resolved claude-mode-only upstream), but this merge is mode-agnostic.
|
||||
if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) {
|
||||
const settingsObj: Record<string, unknown> =
|
||||
effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {};
|
||||
if (options.statusLineCommand) {
|
||||
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Which model a session is running, as far as the server can know it
|
||||
* (`SessionState.displayModel`, shown in the tile grid's and split pane's headers).
|
||||
*
|
||||
* Pure: the session feeds it what it has and publishes the answer through `toState()`.
|
||||
*
|
||||
* ## Sources, strongest first
|
||||
*
|
||||
* 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered
|
||||
* by that endpoint's `modelId`, whatever alias the CLI itself prints.
|
||||
* 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's
|
||||
* statusLine exporter posts `model.display_name` on every render (it follows an
|
||||
* in-session `/model`); a CLI whose registry entry declares
|
||||
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
|
||||
* probe already takes.
|
||||
* 3. **config**: the model the CLI's own config pins for this session, read by the
|
||||
* reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the
|
||||
* dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not
|
||||
* a report from the running CLI, so any report outranks it.
|
||||
* 4. **launch**: the model the session was launched with (claude's `--model` or the
|
||||
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
|
||||
* asked for, not what was reported, so it only shows when nothing reported.
|
||||
*
|
||||
* Nothing known means no field at all: the header shows the harness logo alone, never a
|
||||
* placeholder or a guess.
|
||||
*
|
||||
* ## Untrusted text
|
||||
*
|
||||
* A screen-read model is pane text, and a statusline payload is a POST body: both are
|
||||
* stripped of escape sequences and control characters, whitespace-collapsed and capped
|
||||
* here, and the browser renders the result with `textContent`.
|
||||
*
|
||||
* Tests: `test/session-display-model.test.ts`.
|
||||
*
|
||||
* @module session-display-model
|
||||
*/
|
||||
|
||||
import type { DisplayModel, DisplayModelSource } from './types/session.js';
|
||||
import { stripAnsi } from './utils/index.js';
|
||||
import { getCli } from './config/cli-registry/index.js';
|
||||
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||
|
||||
/** Longest model name published (the header truncates long before this). */
|
||||
export const MAX_DISPLAY_MODEL_CHARS = 64;
|
||||
|
||||
/** A report from the running CLI itself: the sources a restart may restore. */
|
||||
export type ReportedModelSource = Extract<DisplayModelSource, 'statusline' | 'screen'>;
|
||||
|
||||
export interface ReportedModel {
|
||||
model: string;
|
||||
source: ReportedModelSource;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
|
||||
|
||||
/**
|
||||
* A model name fit to publish, or undefined when nothing printable is left.
|
||||
*
|
||||
* @param raw anything; only a string can yield a name
|
||||
*/
|
||||
export function sanitizeModelName(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!clean) return undefined;
|
||||
return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd();
|
||||
}
|
||||
|
||||
/** What a footer field can show that is never the model. */
|
||||
export interface ScreenModelRejects {
|
||||
/** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */
|
||||
rejectWords?: readonly string[];
|
||||
/** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */
|
||||
cwdBasename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern.
|
||||
*
|
||||
* Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern
|
||||
* can anchor on the row above), which keeps the search below the transcript: the
|
||||
* pattern itself must still anchor on chrome only that CLI draws.
|
||||
*
|
||||
* A footer whose model field is switched off shows its NEXT field where the model
|
||||
* was, so the captured field is not taken when it is one of the CLI's declared
|
||||
* non-model words (an effort level, a mode) or the session's own folder name, which a
|
||||
* footer field equal to is the folder, never the model, whatever the CLI. Anything
|
||||
* else the pattern captures is read as the model.
|
||||
*
|
||||
* @param paneText a plain `capture-pane -p` frame, or null when it could not be read
|
||||
* @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model
|
||||
* @param tailRows how many non-blank rows from the bottom the pattern sees
|
||||
* @param rejects fields that are never the model (see {@link ScreenModelRejects})
|
||||
* @returns the model, or undefined when the frame shows none
|
||||
*/
|
||||
export function readScreenModel(
|
||||
paneText: string | null | undefined,
|
||||
pattern: RegExp,
|
||||
tailRows: number = 1,
|
||||
rejects: ScreenModelRejects = {}
|
||||
): string | undefined {
|
||||
if (!paneText) return undefined;
|
||||
const rows = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((row) => row.trimEnd())
|
||||
.filter((row) => row !== '');
|
||||
const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n');
|
||||
// compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a
|
||||
// stale lastIndex would make the same frame match every other call.
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(window);
|
||||
if (!match) return undefined;
|
||||
const field = match[1] ?? '';
|
||||
if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined;
|
||||
if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined;
|
||||
return sanitizeModelName(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a session was launched with, read the way its spawn reads it: where the
|
||||
* model param lives is registry data (`capabilities.model` names the param, the entry's
|
||||
* `legacyConfigField` the `<Mode>Config` object holding it, or the option bag itself for
|
||||
* claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell,
|
||||
* dsh) has none.
|
||||
*
|
||||
* @param mode the session's CLI id
|
||||
* @param bag the session's launch option bag (`model`, `codexConfig`, ...)
|
||||
*/
|
||||
export function launchModelFor(mode: string, bag: Record<string, unknown>): string | undefined {
|
||||
const entry = getCli(mode);
|
||||
const model = entry?.capabilities.model;
|
||||
if (!entry || !model || model.source === 'none') return undefined;
|
||||
const param = model.param ?? 'model';
|
||||
const key = entry.launch.legacyConfigAliases?.[param] ?? param;
|
||||
const value = legacyConfigForMode(mode, bag)?.[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted `displayModel` of a previous run, when it was a report from the CLI
|
||||
* itself: a restart shows it until the next report replaces it. A custom-endpoint or
|
||||
* launch answer is not restored, since the session derives those again by itself.
|
||||
*/
|
||||
export function restoredReportedModel(saved: unknown): ReportedModel | undefined {
|
||||
if (!saved || typeof saved !== 'object') return undefined;
|
||||
const { model, source } = saved as { model?: unknown; source?: unknown };
|
||||
if (source !== 'statusline' && source !== 'screen') return undefined;
|
||||
const name = sanitizeModelName(model);
|
||||
return name ? { model: name, source } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a session header shows, and where it came from.
|
||||
*
|
||||
* @param input.customModelId the custom endpoint's model, when the session is pointed at one
|
||||
* @param input.reported the newest report from the CLI itself
|
||||
* @param input.configModel the model the CLI's config pins for the session
|
||||
* @param input.launchModel the model the session was launched with
|
||||
*/
|
||||
export function resolveDisplayModel(input: {
|
||||
customModelId?: string;
|
||||
reported?: ReportedModel | null;
|
||||
configModel?: string | null;
|
||||
launchModel?: string;
|
||||
}): DisplayModel | undefined {
|
||||
const custom = sanitizeModelName(input.customModelId);
|
||||
if (custom) return { model: custom, source: 'custom-endpoint' };
|
||||
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
|
||||
if (reported && input.reported) return { model: reported, source: input.reported.source };
|
||||
const config = sanitizeModelName(input.configModel);
|
||||
if (config) return { model: config, source: 'config' };
|
||||
const launch = sanitizeModelName(input.launchModel);
|
||||
if (launch) return { model: launch, source: 'launch' };
|
||||
return undefined;
|
||||
}
|
||||
+2
-196
@@ -29,7 +29,6 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
@@ -43,7 +42,6 @@ import {
|
||||
NiceConfig,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
getErrorMessage,
|
||||
isAdvisorModel,
|
||||
isEffortLevel,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
@@ -138,18 +136,7 @@ import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
} from './session-attachment-history.js';
|
||||
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||
import { resolveConfigModel } from './model-config-resolvers.js';
|
||||
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||
import {
|
||||
launchModelFor,
|
||||
readScreenModel,
|
||||
resolveDisplayModel,
|
||||
restoredReportedModel,
|
||||
sanitizeModelName,
|
||||
type ReportedModel,
|
||||
type ReportedModelSource,
|
||||
} from './session-display-model.js';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -225,21 +212,6 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return getCli(mode)?.capabilities.external ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this CLI take the top-level session `model` (claude's per-session `--model`)?
|
||||
*
|
||||
* Read off the registry's model-source capability: only a `claude-settings-file` CLI
|
||||
* (claude) launches on that field. Every other CLI takes its model in its own config object
|
||||
* (`codexConfig.model` and so on), so for them the field is inert, and cron hands the
|
||||
* app-wide default (always a Claude id) to any CLI that has a model at all. `toState()`
|
||||
* publishes and persists the field only where this holds, so a codex cron session never
|
||||
* reports a Claude model it did not run on, and `POST /api/sessions` refuses a `model` for
|
||||
* any CLI where it does not.
|
||||
*/
|
||||
export function cliTakesSessionModel(mode: SessionMode): boolean {
|
||||
return getCli(mode)?.capabilities.model.source === 'claude-settings-file';
|
||||
}
|
||||
|
||||
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
return getCli(mode)?.label ?? mode;
|
||||
@@ -562,27 +534,6 @@ export class Session extends EventEmitter {
|
||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||
/**
|
||||
* The newest model the running CLI reported for itself (its statusline, or its own
|
||||
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
|
||||
* (src/session-display-model.ts). Persisted through `toState()` and restored after a
|
||||
* restart, so an idle session keeps naming its model until the next report.
|
||||
*/
|
||||
private _reportedModel: ReportedModel | null = null;
|
||||
/**
|
||||
* The model the CLI's own config pins for this session (`modelDetect.configResolver`),
|
||||
* read at each pane start, attach or relaunch; null when it pins none. Below any
|
||||
* report from the running CLI in `displayModel`. Not persisted: the next start reads it.
|
||||
*/
|
||||
private _configModel: string | null = null;
|
||||
/** Bumped per config read, so a read that lands after a newer one is dropped. */
|
||||
private _configModelGen = 0;
|
||||
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
|
||||
private _modelLineRe: RegExp | null | undefined = undefined;
|
||||
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
|
||||
private _modelLineRows = 1;
|
||||
/** Resolved with the pattern above: the fields it shows that are never the model. */
|
||||
private _modelRejectWords: readonly string[] = [];
|
||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -707,11 +658,6 @@ export class Session extends EventEmitter {
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// Claude advisor model (code.claude.com/docs/en/advisor), merged into the same launch
|
||||
// `--settings` JSON as ultracode, never the `--advisor` flag (which exits on a refused
|
||||
// pairing). A soft default: /advisor still switches or disables it in-session.
|
||||
private _advisorModel: string | undefined;
|
||||
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
|
||||
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
|
||||
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
|
||||
@@ -828,8 +774,6 @@ export class Session extends EventEmitter {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
|
||||
advisorModel?: string;
|
||||
/** 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. */
|
||||
@@ -840,8 +784,6 @@ export class Session extends EventEmitter {
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
paneExit?: PaneExit;
|
||||
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||
displayModel?: DisplayModel;
|
||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
@@ -992,9 +934,6 @@ export class Session extends EventEmitter {
|
||||
if (config.effort && isEffortLevel(config.effort)) {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
if (isAdvisorModel(config.advisorModel)) {
|
||||
this._advisorModel = config.advisorModel;
|
||||
}
|
||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
@@ -1009,7 +948,6 @@ export class Session extends EventEmitter {
|
||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1303,9 +1241,6 @@ export class Session extends EventEmitter {
|
||||
} finally {
|
||||
this._paneLifecycleOps--;
|
||||
this._paneStartedAt = Date.now();
|
||||
// A start, attach or relaunch is when the CLI read its config, so it is when
|
||||
// the model that config pins is read here too.
|
||||
this._refreshConfigModel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1892,12 +1827,7 @@ export class Session extends EventEmitter {
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// Claude only: for any other CLI `_model` is inert (its model lives in its own config
|
||||
// object) and may be the app-wide Claude default cron handed it.
|
||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||
advisorModel: this._advisorModel,
|
||||
customModel: this.customModel,
|
||||
displayModel: this.displayModel,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
@@ -2275,7 +2205,6 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
||||
effort: this._effort,
|
||||
advisorModel: this._advisorModel,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
@@ -2738,7 +2667,6 @@ export class Session extends EventEmitter {
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
advisorModel: this._advisorModel,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
@@ -2863,8 +2791,7 @@ export class Session extends EventEmitter {
|
||||
this._allowedTools,
|
||||
this._effort,
|
||||
this.cliPinnedName,
|
||||
getClaudeCliVersion(),
|
||||
this._advisorModel
|
||||
getClaudeCliVersion()
|
||||
);
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
@@ -3174,130 +3101,9 @@ export class Session extends EventEmitter {
|
||||
this._lastPaneProbeWorking =
|
||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||
this._readWatching(text);
|
||||
this._readScreenModel(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the model the CLI's own footer names off the same capture, for a CLI whose
|
||||
* registry entry declares `capabilities.modelDetect`.
|
||||
*
|
||||
* Unlike `_readWatching`, a capture that could not be read, or a footer the pattern
|
||||
* does not find (a popup covering it, a footer turned off), KEEPS the last model. The
|
||||
* two are not symmetric: background work ends and its badge must go, while a model does
|
||||
* not stop running because something was drawn over the row that names it.
|
||||
*/
|
||||
private _readScreenModel(paneText: string | null): void {
|
||||
if (paneText === null) return;
|
||||
const pattern = this._modelLinePattern();
|
||||
if (!pattern) return;
|
||||
const model = readScreenModel(paneText, pattern, this._modelLineRows, {
|
||||
rejectWords: this._modelRejectWords,
|
||||
// A footer field equal to the folder this session runs in is the folder, never the
|
||||
// model: the generic half of the rule, for every CLI.
|
||||
cwdBasename: basename(this.workingDir),
|
||||
});
|
||||
if (model) this.noteReportedModel('screen', model);
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex reading this CLI's model off its footer, or null for a CLI that declares
|
||||
* none. Compiled once per session through `compileVersionRegex()` (null, never a throw,
|
||||
* for a pattern it refuses), like the working- and watching-line patterns.
|
||||
*/
|
||||
private _modelLinePattern(): RegExp | null {
|
||||
if (this._modelLineRe === undefined) {
|
||||
const detect = getCli(this.mode)?.capabilities.modelDetect;
|
||||
this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null;
|
||||
this._modelLineRows = detect?.screenLines ?? 1;
|
||||
this._modelRejectWords = detect?.rejectWords ?? [];
|
||||
}
|
||||
return this._modelLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a model the running CLI reported for itself: its statusline (claude's
|
||||
* exporter, via `POST /api/status-telemetry`) or its own footer. The newest report
|
||||
* wins whatever its source. An empty or unprintable report changes nothing.
|
||||
*
|
||||
* @returns true when the reported model changed (and `displayModelChanged` was emitted)
|
||||
*/
|
||||
noteReportedModel(source: ReportedModelSource, raw: unknown): boolean {
|
||||
const model = sanitizeModelName(raw);
|
||||
if (!model) return false;
|
||||
if (this._reportedModel?.model === model && this._reportedModel.source === source) return false;
|
||||
this._reportedModel = { model, source };
|
||||
// The status does not change with it, so it needs a broadcast (and a persist) of its own.
|
||||
this.emit('displayModelChanged');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model this session runs as far as the server knows, and where that came from:
|
||||
* the custom endpoint's model, else the newest report from the CLI, else the launch
|
||||
* model (src/session-display-model.ts). Undefined when none is known.
|
||||
*/
|
||||
get displayModel(): DisplayModel | undefined {
|
||||
return resolveDisplayModel({
|
||||
customModelId: this._customModel?.modelId,
|
||||
reported: this._reportedModel,
|
||||
configModel: this._configModel,
|
||||
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The same option bag the spawn reads its launch params from: `model` at the top for
|
||||
* claude (the `--model` or app-wide default it was created with; inert for every other
|
||||
* CLI, which is why it is not handed over for them), each other CLI's own
|
||||
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
|
||||
*/
|
||||
private _launchOptionBag(): Record<string, unknown> {
|
||||
return {
|
||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the model this session's CLI config pins, with the reader its registry entry
|
||||
* names (`capabilities.modelDetect.configResolver`), and announce a change. Async and
|
||||
* bounded (the reader probes before it reads); a read that lands after a newer one,
|
||||
* or after the session stopped, is dropped. A remote or docker session's CLI reads its
|
||||
* config on another machine or in its container, so nothing local is read for it.
|
||||
*/
|
||||
private _refreshConfigModel(): void {
|
||||
const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver;
|
||||
if (!name || this._remote || this._docker) return;
|
||||
const gen = ++this._configModelGen;
|
||||
const overrides = this._envOverrides;
|
||||
resolveConfigModel(name, {
|
||||
config: legacyConfigForMode(this.mode, this._launchOptionBag()),
|
||||
// The session's own env first (already clamped for a non-granted owner), then the
|
||||
// server's: what the pane's CLI inherits.
|
||||
env: (key) => overrides?.[key] ?? process.env[key],
|
||||
}).then(
|
||||
(model) => {
|
||||
if (gen !== this._configModelGen || this._isStopped) return;
|
||||
// Sanitized where it is published (resolveDisplayModel), like every source.
|
||||
const next = model || null;
|
||||
if (next === this._configModel) return;
|
||||
this._configModel = next;
|
||||
this.emit('displayModelChanged');
|
||||
},
|
||||
() => {
|
||||
/* A reader answers null on doubt and never throws; a throw changes nothing. */
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the background-work chip off the same capture the working probe just took.
|
||||
*
|
||||
|
||||
@@ -882,8 +882,6 @@ export function buildSpawnCommand(options: {
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */
|
||||
advisorModel?: string;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
|
||||
@@ -2085,7 +2083,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
@@ -2173,7 +2170,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
sessionName: cliName,
|
||||
});
|
||||
@@ -2401,7 +2397,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
unsetEnvKeys,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
docker,
|
||||
cliName,
|
||||
@@ -2440,7 +2435,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
@@ -157,13 +157,6 @@ export interface CaseInfo {
|
||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||
/** Whether this is a linked local folder */
|
||||
linked?: boolean;
|
||||
/**
|
||||
* The case folder did not answer (an unreachable network mount, or an error other
|
||||
* than "no such file"), or its probe was refused because folders on other unreachable
|
||||
* mounts are still not answering, so whether it still exists is unknown. A refused
|
||||
* probe can set this on a healthy linked case. Absent = it answered.
|
||||
*/
|
||||
unreachable?: boolean;
|
||||
/**
|
||||
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
|
||||
* (the packaged skill's workers, or any spawn naming a parent session), read back
|
||||
|
||||
+1
-3
@@ -24,10 +24,9 @@
|
||||
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
|
||||
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
@@ -73,4 +72,3 @@ export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
export * from './mcp-sync.js';
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
|
||||
*
|
||||
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
|
||||
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
|
||||
*/
|
||||
|
||||
/** One participating CLI in a sync result. */
|
||||
export interface McpSyncTargetResult {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The config file read (and written). For a `skipped` target, the unresolved location. */
|
||||
file: string;
|
||||
/**
|
||||
* `absent`: not installed and no config file, so neither read nor created.
|
||||
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
|
||||
* var is a relative path), so it is neither read nor written; `error` says why.
|
||||
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
|
||||
* `failed`: a read or write error (the file may be unchanged).
|
||||
*/
|
||||
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
|
||||
/** Why the target is not `ok`. Position or category only, never file content. */
|
||||
error?: string;
|
||||
servers: string[];
|
||||
/** Servers added (apply) or that would be added (plan). */
|
||||
added: string[];
|
||||
/** Missing servers this dialect cannot express. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** The `data` of `GET`/`POST /api/mcp-sync`. */
|
||||
export interface McpSyncResult {
|
||||
applied: boolean;
|
||||
targets: McpSyncTargetResult[];
|
||||
/** Names defined differently by different CLIs; existing definitions are left untouched. */
|
||||
conflicts: string[];
|
||||
/** Names left out because the only definitions are switched off in their own CLI. */
|
||||
disabled: string[];
|
||||
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
|
||||
unsupported: string[];
|
||||
}
|
||||
+2
-43
@@ -6,17 +6,13 @@
|
||||
* Key exports:
|
||||
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
|
||||
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
|
||||
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
|
||||
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
|
||||
*
|
||||
* Persistence:
|
||||
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
|
||||
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
|
||||
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
|
||||
*
|
||||
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
|
||||
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
|
||||
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
|
||||
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
|
||||
* `POST /api/push/subscribe`. No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
/** A registered push subscription */
|
||||
@@ -36,40 +32,3 @@ export interface VapidKeys {
|
||||
privateKey: string;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
/** Services the webhook channel can format a message for. */
|
||||
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
|
||||
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
|
||||
|
||||
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
|
||||
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
|
||||
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
|
||||
|
||||
export type WebhookUrgency = 'critical' | 'warning' | 'info';
|
||||
|
||||
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
|
||||
export interface WebhookConfig {
|
||||
enabled: boolean;
|
||||
kind: WebhookKind;
|
||||
url: string;
|
||||
scope: WebhookScope;
|
||||
}
|
||||
|
||||
/** One delivery attempt. `error` never contains the URL. */
|
||||
export interface WebhookResult {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
|
||||
export interface WebhookStatus {
|
||||
enabled: boolean;
|
||||
kind: WebhookKind;
|
||||
scope: WebhookScope;
|
||||
hasUrl: boolean;
|
||||
/** Scheme + host only; the path and query are the secret. */
|
||||
urlMasked: string;
|
||||
lastResult: WebhookResult | null;
|
||||
}
|
||||
|
||||
+1
-72
@@ -12,7 +12,7 @@
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||
* - 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)
|
||||
@@ -377,36 +377,6 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
|
||||
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0).
|
||||
* Which of them a given model honours is codex's business; Codeman only keeps the value
|
||||
* to a known word, since it lands in the launch argv.
|
||||
*/
|
||||
export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
|
||||
|
||||
/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=<level>` */
|
||||
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
|
||||
|
||||
/**
|
||||
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
|
||||
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
|
||||
* out on purpose: it can call an advisor but never act as one.
|
||||
*/
|
||||
export const ADVISOR_MODEL_ALIASES = ['fable', 'opus', 'sonnet'] as const;
|
||||
|
||||
/** A full model id in one of the advisor-capable families, e.g. `claude-opus-5-5`. */
|
||||
const ADVISOR_MODEL_ID_PATTERN = /^claude-(?:fable|opus|sonnet)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Type guard: is the value an advisor model Codeman will pass to claude? An alias from
|
||||
* ADVISOR_MODEL_ALIASES or a full fable/opus/sonnet model id. ⚠️ This allowlist is also the
|
||||
* injection guard: the value is rendered inside the single-quoted `--settings` JSON argument.
|
||||
*/
|
||||
export function isAdvisorModel(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || value.length > 64) return false;
|
||||
return (ADVISOR_MODEL_ALIASES as readonly string[]).includes(value) || ADVISOR_MODEL_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** OpenCode session configuration */
|
||||
export interface OpenCodeConfig {
|
||||
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
|
||||
@@ -428,8 +398,6 @@ export type CodexRenderMode = 'hybrid';
|
||||
export interface CodexConfig {
|
||||
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Reasoning effort for this session. Passed via --config model_reasoning_effort=<level>. */
|
||||
reasoningEffort?: CodexReasoningEffort;
|
||||
/** Resume a previous codex conversation by session id (passed via --resume) */
|
||||
resumeSessionId?: string;
|
||||
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
||||
@@ -641,24 +609,6 @@ export interface CustomModelSelection {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a session's {@link DisplayModel} came from (src/session-display-model.ts):
|
||||
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
|
||||
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
|
||||
* - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch.
|
||||
* - `config`: what the CLI's own config pins for this session
|
||||
* (`capabilities.modelDetect.configResolver`), while its screen names none.
|
||||
* - `launch`: what the session was launched with; nothing has reported since.
|
||||
*/
|
||||
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch';
|
||||
|
||||
/** The model a session runs as far as the server knows, for a session header. */
|
||||
export interface DisplayModel {
|
||||
/** Display text: sanitized (no control characters) and at most 64 characters. */
|
||||
model: string;
|
||||
source: DisplayModelSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full custom-model selection a session keeps: the public selection plus the
|
||||
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
||||
@@ -845,17 +795,6 @@ export interface SessionState {
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
|
||||
advisorModel?: string;
|
||||
/**
|
||||
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
|
||||
* the app-wide default when there was none. Persisted so a recovered session relaunches on
|
||||
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
|
||||
* the CLI's banner reports. Claude sessions only (`cliTakesSessionModel()`): every other CLI
|
||||
* keeps its model in its own config object (`codexConfig.model` and so on), and this is
|
||||
* absent for them.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
|
||||
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
|
||||
@@ -865,16 +804,6 @@ export interface SessionState {
|
||||
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
||||
*/
|
||||
customModel?: CustomModelSelection;
|
||||
/**
|
||||
* The model this session runs, as far as the server knows it, and where that came from
|
||||
* (src/session-display-model.ts): the custom endpoint's model, else the newest report
|
||||
* from the CLI itself (statusline or its own footer), else the model its config pins,
|
||||
* else the launch model. Absent when
|
||||
* none is known; a session header then shows the harness alone. Untrusted display text
|
||||
* (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen`
|
||||
* value is restored after a restart until the next report replaces it.
|
||||
*/
|
||||
displayModel?: DisplayModel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Bounded existence probe for user-chosen paths.
|
||||
*
|
||||
* A linked case can live on a network mount (NFS, SMB, sshfs). When that mount
|
||||
* goes unreachable, a hard mount makes `stat()` wait forever. A synchronous
|
||||
* probe (`existsSync`) on such a path blocks the event loop and freezes the
|
||||
* whole web server; even an async `stat()` never settles and permanently holds
|
||||
* one of libuv's few threadpool workers, which every other `fs`, `dns.lookup`
|
||||
* and `crypto` call in the process shares.
|
||||
*
|
||||
* The probe therefore answers one of THREE things, never two:
|
||||
* - `'present'` / `'absent'`: the filesystem answered (ENOENT and ENOTDIR are
|
||||
* the only errors that mean absent);
|
||||
* - `'unknown'`: it did not answer in `PATH_PROBE_TIMEOUT_MS`, it answered with
|
||||
* some other error (EIO from a soft mount that gave up, EACCES), or the probe
|
||||
* was refused (below). "Unknown" is NOT "absent": a caller that would create,
|
||||
* scaffold or 404 on absence must not do so on unknown.
|
||||
*
|
||||
* And it keeps a dead mount from draining the threadpool:
|
||||
* - one in-flight probe per path, shared by concurrent callers;
|
||||
* - a path whose probe timed out is "stalled" until that stat finally settles.
|
||||
* Paths NEAR a stalled one are answered "unknown" without a new stat, so one
|
||||
* dead mount costs one worker, not one per case and file on it. "Near" means on
|
||||
* the same mount when that mount is a network or FUSE filesystem (NFS, SMB,
|
||||
* sshfs and the like): under the deepest mount point holding the stalled path,
|
||||
* with its type, read from `/proc/self/mounts` (procfs, which never waits on the
|
||||
* dead filesystem). Otherwise it narrows to the stalled path and everything under
|
||||
* it: when the deepest mount is local (a path typed under a local `/home` can
|
||||
* reach a NAS through a symlink, and must not take the rest of `/home` with it),
|
||||
* is `/`, or the table is unavailable (not Linux). Unrelated paths are probed
|
||||
* normally;
|
||||
* - once `MAX_STALLED_PATH_PROBES` stalled stats are pending, new probes are
|
||||
* refused process-wide (answered "unknown"), since each would risk another
|
||||
* worker. Probes merely in flight do not count, so concurrent healthy probes
|
||||
* never get refused. A caller acting on ONE path at a user's explicit request
|
||||
* (opening a case, starting a session in it) may pass `{ pastCap: true }`: its
|
||||
* probe is still bounded and still recorded as stalled if it hangs (so a dead
|
||||
* path costs at most one worker however often it is retried), but it is not
|
||||
* refused just because unrelated mounts are dead. Bulk scans (the case list)
|
||||
* keep the cap; the per-spawn hook and statusLine helpers retry one refused
|
||||
* probe past it and then skip a path that still answers "unknown", rather than
|
||||
* touch it with an unbounded call. `pastCap` still stops at
|
||||
* `PATH_PROBE_STALL_CEILING` (the threadpool size minus one), so explicit
|
||||
* requests against several dead paths can never take the last worker.
|
||||
*
|
||||
* Both events are logged once (`console.warn`): a path's first stall, and the
|
||||
* cap engaging, so "my case vanished" and "hooks stopped firing" leave a trace.
|
||||
*
|
||||
* Writers should not use this at all: a writer that must tell "missing" apart
|
||||
* from "unreachable" wants an ENOENT-aware async `lstat` (see
|
||||
* `pathExistsForWrite` in hooks-config.ts).
|
||||
*
|
||||
* @module utils/bounded-path-probe
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../config/path-probe.js';
|
||||
|
||||
/** What a probe could establish about a path. */
|
||||
export type PathProbeState = 'present' | 'absent' | 'unknown';
|
||||
/** Like {@link PathProbeState}, with "present" split by whether it is a directory. */
|
||||
export type PathProbeKind = 'directory' | 'file' | 'absent' | 'unknown';
|
||||
|
||||
const inFlight = new Map<string, Promise<PathProbeKind>>();
|
||||
/** Stalled path -> the directory whose subtree is answered "unknown" while it stays stalled. */
|
||||
const stalled = new Map<string, string>();
|
||||
let capWarned = false;
|
||||
|
||||
async function statKind(path: string): Promise<PathProbeKind> {
|
||||
try {
|
||||
return (await fs.stat(path)).isDirectory() ? 'directory' : 'file';
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function isWithin(path: string, root: string): boolean {
|
||||
if (path === root) return true;
|
||||
return path.startsWith(root.endsWith(sep) ? root : root + sep);
|
||||
}
|
||||
|
||||
/** Filesystem types whose stall means the whole mount is gone (network and FUSE). */
|
||||
const REMOTE_FS_TYPES = new Set([
|
||||
'nfs',
|
||||
'nfs4',
|
||||
'cifs',
|
||||
'smb3',
|
||||
'smbfs',
|
||||
'9p',
|
||||
'ceph',
|
||||
'glusterfs',
|
||||
'afs',
|
||||
'lustre',
|
||||
'davfs',
|
||||
]);
|
||||
|
||||
function isRemoteFsType(fsType: string): boolean {
|
||||
return REMOTE_FS_TYPES.has(fsType) || fsType.startsWith('fuse.');
|
||||
}
|
||||
|
||||
/** Deepest mount holding `abs`, from the kernel's mount table; undefined when unreadable. */
|
||||
function mountOf(abs: string): { mountPoint: string; fsType: string } | undefined {
|
||||
let table: string;
|
||||
try {
|
||||
table = readFileSync('/proc/self/mounts', 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
let best: { mountPoint: string; fsType: string } | undefined;
|
||||
for (const line of table.split('\n')) {
|
||||
const [, field, fsType] = line.split(' ');
|
||||
if (!field || !fsType) continue;
|
||||
// The table octal-escapes space, tab, newline and backslash in mount points.
|
||||
const mountPoint = field.replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)));
|
||||
if (isWithin(abs, mountPoint) && (!best || mountPoint.length > best.mountPoint.length)) {
|
||||
best = { mountPoint, fsType };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtree a stalled path takes down with it (see the module comment): its
|
||||
* mount when that is a network or FUSE filesystem, else just the path itself.
|
||||
*/
|
||||
function stallScope(abs: string): string {
|
||||
const mount = mountOf(abs);
|
||||
return mount && mount.mountPoint !== '/' && isRemoteFsType(mount.fsType) ? mount.mountPoint : abs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `path` is near a path whose probe is still stalled (see the module
|
||||
* comment), i.e. whether the probe would answer "unknown" for it without a stat.
|
||||
* Lets a caller tell "this workspace sits on the dead mount" apart from "the
|
||||
* probe was refused for capacity".
|
||||
*/
|
||||
export function isNearStalledPath(path: string): boolean {
|
||||
const abs = resolve(path);
|
||||
for (const scope of stalled.values()) {
|
||||
if (isWithin(abs, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Options for {@link probePathKind} / {@link probePath}. */
|
||||
export interface PathProbeOptions {
|
||||
/** Probe even while the stall cap is engaged (see the module comment). */
|
||||
pastCap?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe `path` without letting an unresponsive filesystem block the caller for
|
||||
* longer than `PATH_PROBE_TIMEOUT_MS`. Follows symlinks, like `stat()`.
|
||||
*/
|
||||
export async function probePathKind(path: string, options: PathProbeOptions = {}): Promise<PathProbeKind> {
|
||||
const abs = resolve(path);
|
||||
if (isNearStalledPath(abs)) return 'unknown';
|
||||
|
||||
let probe = inFlight.get(abs);
|
||||
if (!probe) {
|
||||
// pastCap lifts the bulk cap, never the ceiling that keeps one worker free.
|
||||
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) {
|
||||
if (!capWarned) {
|
||||
capWarned = true;
|
||||
console.warn(
|
||||
`[path-probe] ${stalled.size} path probes are stalled on unresponsive filesystems; ` +
|
||||
'not starting new ones until one answers (paths read as unknown meanwhile)'
|
||||
);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
probe = statKind(abs);
|
||||
const started = probe;
|
||||
inFlight.set(abs, started);
|
||||
void started.finally(() => {
|
||||
inFlight.delete(abs);
|
||||
stalled.delete(abs);
|
||||
if (stalled.size < MAX_STALLED_PATH_PROBES) capWarned = false;
|
||||
});
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
probe,
|
||||
new Promise<PathProbeKind>((resolveTimeout) => {
|
||||
timer = setTimeout(() => {
|
||||
if (inFlight.get(abs) === probe && !stalled.has(abs)) {
|
||||
stalled.set(abs, stallScope(abs));
|
||||
console.warn(
|
||||
`[path-probe] ${abs} did not answer within ${PATH_PROBE_TIMEOUT_MS} ms ` +
|
||||
'(unreachable mount?); treating it and its neighbours as unknown until it does'
|
||||
);
|
||||
}
|
||||
resolveTimeout('unknown');
|
||||
}, PATH_PROBE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a probe of `path` answers "unknown" right now: its mount is not answering
|
||||
* (`'stalled'`, it is near a stalled probe), new probes are refused because enough
|
||||
* UNRELATED paths are stalled (`'refused'`; `pastCap` picks which limit applies), or
|
||||
* neither, so the filesystem answered with an error such as EACCES or EIO
|
||||
* (`'unreadable'`). For messages only: it reads the state now, not at probe time.
|
||||
*/
|
||||
export function unknownPathReason(path: string, options: PathProbeOptions = {}): 'stalled' | 'refused' | 'unreadable' {
|
||||
if (isNearStalledPath(path)) return 'stalled';
|
||||
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) return 'refused';
|
||||
return 'unreadable';
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing sentence for an "unknown" probe of `path` (`label` names it, e.g.
|
||||
* "workingDir"). A refused probe says so, rather than blaming a folder that was never
|
||||
* checked: at the ceiling every new folder reads "unknown" until a dead mount answers.
|
||||
*/
|
||||
export function describeUnknownPath(label: string, path: string, options: PathProbeOptions = {}): string {
|
||||
return unknownPathReason(path, options) === 'refused'
|
||||
? `${label} was not checked: folders on other unreachable mounts are still not answering, ` +
|
||||
`so Codeman is not checking new folders until one does (see the server log): ${path}`
|
||||
: `${label} is not responding or not readable: ${path}`;
|
||||
}
|
||||
|
||||
/** Tri-state probe of `path`; see the module comment for what "unknown" means. */
|
||||
export async function probePath(path: string, options: PathProbeOptions = {}): Promise<PathProbeState> {
|
||||
const kind = await probePathKind(path, options);
|
||||
return kind === 'directory' || kind === 'file' ? 'present' : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` only when `path` is known to exist. For DISPLAY decisions only (does a
|
||||
* case have a CLAUDE.md): it folds "unknown" into `false`, so never use it to
|
||||
* decide that something is absent and may be created, scaffolded or reported
|
||||
* missing; use {@link probePath} for that.
|
||||
*/
|
||||
export async function boundedPathExists(path: string): Promise<boolean> {
|
||||
return (await probePath(path)) === 'present';
|
||||
}
|
||||
@@ -122,8 +122,7 @@ export interface ProductionCliResolverHostOptions {
|
||||
allowRealIoUnderVitest?: boolean;
|
||||
}
|
||||
|
||||
/** An executable regular file. Exported for `codeman doctor`, which must judge a candidate the same way. */
|
||||
export function isExecutableRegularFile(path: string): boolean {
|
||||
function isExecutableRegularFile(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false;
|
||||
accessSync(path, constants.X_OK);
|
||||
|
||||
@@ -156,11 +156,6 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
||||
/** Profile directory names that are not profiles. */
|
||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||
|
||||
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
|
||||
export function isProfileDirName(name: string): boolean {
|
||||
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
|
||||
}
|
||||
|
||||
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
|
||||
@@ -179,19 +174,7 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||
*/
|
||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||
try {
|
||||
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile from its directory name and the text of its `package.json`, or null when
|
||||
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
|
||||
* same classification as the inventory below.
|
||||
*/
|
||||
export function deepSeekProfileFromManifest(name: string, raw: 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') : [];
|
||||
@@ -215,7 +198,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
|
||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { isExecutableRegularFile } from './cli-executable-resolver.js';
|
||||
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
|
||||
|
||||
export interface EnvDetectionInputs {
|
||||
@@ -64,8 +62,6 @@ export interface ProbeHost {
|
||||
environment: ProbeEnvironment;
|
||||
which(bin: string): string | null;
|
||||
fileExists(path: string): boolean;
|
||||
/** An executable regular file, the run mode's own test for a `searchDirs` candidate. */
|
||||
isExecutableFile(path: string): boolean;
|
||||
runVersion(bin: string, args: string[]): string | null;
|
||||
windowsProgramRoots(): string[];
|
||||
windowsFileVersion(winPath: string): string | null;
|
||||
@@ -98,33 +94,18 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
|
||||
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
|
||||
|
||||
if (spec.resolver.kind === 'path') {
|
||||
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
|
||||
const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver;
|
||||
for (const bin of bins) {
|
||||
// The same candidate order and the same per-candidate test as the run mode's resolver
|
||||
// (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under
|
||||
// a service the PATH is minimal and the run mode finds the CLI through those dirs, so
|
||||
// the doctor must too. A search-dir candidate counts only as an absolute path to an
|
||||
// executable regular file, so a relative dir from a custom clis.json or a file without
|
||||
// the x bit reads as missing here exactly as it does in the Run menu.
|
||||
const candidates: string[] = [];
|
||||
const onPath = host.which(bin);
|
||||
if (onPath && isAbsolute(onPath)) candidates.push(onPath);
|
||||
for (const dir of searchDirs ?? []) {
|
||||
const candidate = join(dir, bin);
|
||||
if (candidates.includes(candidate)) continue; // a search dir that is also on the PATH
|
||||
if (isAbsolute(candidate) && host.isExecutableFile(candidate)) candidates.push(candidate);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
|
||||
const out = host.runVersion(candidate, [versionArg ?? '--version']);
|
||||
const resolved = host.which(bin);
|
||||
if (resolved) {
|
||||
const out = host.runVersion(bin, [versionArg ?? '--version']);
|
||||
const version = out ? extractVersion(out, versionRegex) : undefined;
|
||||
// A generic binary name that prints the wrong thing is some OTHER program (see
|
||||
// PathResolver.requireVersionMatch). Try the next candidate, then report MISSING;
|
||||
// the alternative is claiming a tool is installed that the feature's own resolver
|
||||
// rejects, or missing one it accepts (an npm squatter on the PATH in front of the
|
||||
// real grok in ~/.grok/bin), which reads as "the mode is broken".
|
||||
// PathResolver.requireVersionMatch). Keep looking, then report MISSING; the
|
||||
// alternative is claiming a tool is installed that the feature's own resolver
|
||||
// rejects, which reads as "the mode is broken" rather than "install it".
|
||||
if (requireVersionMatch && !version) continue;
|
||||
return finalize(base, tool, candidate, version);
|
||||
return finalize(base, tool, resolved, version);
|
||||
}
|
||||
}
|
||||
return { ...base, status: 'missing', installHint };
|
||||
@@ -151,16 +132,11 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul
|
||||
return registry.map((tool) => checkTool(tool, host));
|
||||
}
|
||||
|
||||
// SIGKILL on every probe below: execFileSync's `timeout` only SENDS the kill signal and then
|
||||
// keeps waiting for the child, so a `--version` that ignores the default SIGTERM would hold
|
||||
// the doctor (now a Settings button) until GET /api/doctor's own timeout, then be orphaned.
|
||||
// Same reasoning as the resolver host in cli-executable-resolver.ts.
|
||||
function safeWhich(bin: string): string | null {
|
||||
try {
|
||||
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const first = out.split(/\r?\n/)[0]?.trim();
|
||||
return first && existsSync(first) ? first : null;
|
||||
@@ -175,7 +151,6 @@ function safeRunVersion(bin: string, args: string[]): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
|
||||
@@ -212,12 +187,11 @@ function readWindowsFileVersion(winPath: string): string | null {
|
||||
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const out = execFileSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, killSignal: 'SIGKILL' }
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
@@ -235,7 +209,6 @@ export function createRealHost(): ProbeHost {
|
||||
environment,
|
||||
which: safeWhich,
|
||||
fileExists: existsSync,
|
||||
isExecutableFile: isExecutableRegularFile,
|
||||
runVersion: safeRunVersion,
|
||||
windowsProgramRoots: listWindowsProgramRoots,
|
||||
windowsFileVersion: readWindowsFileVersion,
|
||||
|
||||
@@ -68,12 +68,3 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
probePath,
|
||||
probePathKind,
|
||||
isNearStalledPath,
|
||||
unknownPathReason,
|
||||
} from './bounded-path-probe.js';
|
||||
export type { PathProbeState, PathProbeKind, PathProbeOptions } from './bounded-path-probe.js';
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
|
||||
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
|
||||
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
|
||||
* judged before anything is created:
|
||||
*
|
||||
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
|
||||
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
|
||||
* a case this accepts is one a session can actually start in;
|
||||
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
|
||||
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
|
||||
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
|
||||
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
|
||||
* around it;
|
||||
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
|
||||
* folder listed both as a local case and as a linked one would make deleting it remove files;
|
||||
* - its parent must already exist (one folder is created, never a whole chain), and the folder
|
||||
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
|
||||
* job, and silently scaffolding into someone's project is the one thing this must never do);
|
||||
* - it must not be a symlink.
|
||||
*
|
||||
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
|
||||
* so it can be tested without a disk.
|
||||
*
|
||||
* @module web/case-path
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path';
|
||||
import { isValidWorkingDir } from './schemas.js';
|
||||
import { describeUnknownPath, probePath } from '../utils/index.js';
|
||||
|
||||
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
|
||||
const BLOCKED_SYSTEM_ROOTS = [
|
||||
'/bin',
|
||||
'/boot',
|
||||
'/dev',
|
||||
'/etc',
|
||||
'/lib',
|
||||
'/lib32',
|
||||
'/lib64',
|
||||
'/proc',
|
||||
'/run',
|
||||
'/sbin',
|
||||
'/sys',
|
||||
'/usr',
|
||||
];
|
||||
|
||||
/** Home-relative trees that hold credentials or other tools' own configuration. */
|
||||
const BLOCKED_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.claude', '.codex', '.gemini'];
|
||||
|
||||
export interface NewCasePathContext {
|
||||
home: string;
|
||||
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
|
||||
dataDir: string;
|
||||
/**
|
||||
* The cases directories (the caller's own and the shared one). A folder in one of them is already
|
||||
* listed as a local case, so it must not be registered as a linked one too.
|
||||
*/
|
||||
casesDirs?: readonly string[];
|
||||
}
|
||||
|
||||
export type NewCasePathResult =
|
||||
| { ok: true; path: string; existedEmpty: boolean }
|
||||
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS' | 'UNREACHABLE'; reason: string };
|
||||
|
||||
const isWithin = (child: string, root: string): boolean =>
|
||||
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
|
||||
|
||||
/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
|
||||
export function expandHome(raw: string, home: string): string {
|
||||
if (raw === '~') return home;
|
||||
if (raw.startsWith('~/')) return join(home, raw.slice(2));
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a case may not live at this (already absolute and normalised) path, or null. `systemRoots`
|
||||
* defaults to the system trees as spelled; pass their symlink-resolved forms to judge a resolved path.
|
||||
*/
|
||||
export function blockedReason(
|
||||
absPath: string,
|
||||
ctx: NewCasePathContext,
|
||||
systemRoots: readonly string[] = BLOCKED_SYSTEM_ROOTS
|
||||
): string | null {
|
||||
if (absPath === sep) return 'The filesystem root cannot be a case';
|
||||
for (const root of systemRoots) {
|
||||
if (isWithin(absPath, root)) return `${root} is a system directory`;
|
||||
}
|
||||
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
|
||||
for (const dir of BLOCKED_HOME_DIRS) {
|
||||
if (isWithin(absPath, join(ctx.home, dir))) return `~/${dir} holds credentials or another tool's configuration`;
|
||||
}
|
||||
if (isWithin(absPath, ctx.dataDir)) return "Codeman's own data folder cannot be a case";
|
||||
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
|
||||
// the one this process uses.
|
||||
if (absPath.startsWith(ctx.home + sep)) {
|
||||
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
|
||||
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
|
||||
}
|
||||
for (const dir of ctx.casesDirs ?? []) {
|
||||
if (isWithin(absPath, dir)) {
|
||||
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `p` with its symlinks resolved, or `p` itself when it does not exist (or cannot be read). */
|
||||
async function realpathOr(p: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/** The context and system roots with their symlinks resolved, for judging a resolved path. */
|
||||
async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathContext, string[]]> {
|
||||
const [home, dataDir, casesDirs, systemRoots] = await Promise.all([
|
||||
realpathOr(ctx.home),
|
||||
realpathOr(ctx.dataDir),
|
||||
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
|
||||
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
|
||||
]);
|
||||
return [{ home, dataDir, casesDirs }, systemRoots];
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
|
||||
* Never creates anything.
|
||||
*/
|
||||
export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext): Promise<NewCasePathResult> {
|
||||
const typed = raw.trim();
|
||||
if (!typed) return { ok: false, code: 'INVALID', reason: 'Enter a folder path' };
|
||||
const expanded = expandHome(typed, ctx.home);
|
||||
if (!isValidWorkingDir(expanded)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID',
|
||||
reason: 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)',
|
||||
};
|
||||
}
|
||||
|
||||
const target = resolve(expanded);
|
||||
const typedBlock = blockedReason(target, ctx);
|
||||
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };
|
||||
|
||||
// Bounded first: the parent can sit on a network mount that stopped answering, where the
|
||||
// realpath/stat/lstat/readdir below would each hold a threadpool worker until it returns.
|
||||
// It is one folder the user named, so the probe may pass the bulk cap (never the ceiling).
|
||||
const parentState = await probePath(dirname(target), { pastCap: true });
|
||||
if (parentState === 'absent') {
|
||||
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
|
||||
}
|
||||
if (parentState === 'unknown') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'UNREACHABLE',
|
||||
reason: describeUnknownPath('The parent folder', dirname(target), { pastCap: true }),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
|
||||
let realParent: string;
|
||||
try {
|
||||
realParent = await fs.realpath(dirname(target));
|
||||
if (!(await fs.stat(realParent)).isDirectory()) {
|
||||
return { ok: false, code: 'INVALID', reason: `${dirname(target)} is not a folder` };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
|
||||
}
|
||||
const real = join(realParent, basename(target));
|
||||
// The resolved path against the roots as given AND as resolved: with home reached through a link, a
|
||||
// link to <real home>/.ssh is only caught by the resolved home; on macOS /etc is /private/etc.
|
||||
const [resolvedCtx, resolvedSystemRoots] = await resolvedPolicy(ctx);
|
||||
const realBlock = blockedReason(real, ctx) ?? blockedReason(real, resolvedCtx, resolvedSystemRoots);
|
||||
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };
|
||||
|
||||
try {
|
||||
const st = await fs.lstat(real);
|
||||
if (st.isSymbolicLink()) return { ok: false, code: 'INVALID', reason: `${target} is a symbolic link` };
|
||||
if (!st.isDirectory()) return { ok: false, code: 'INVALID', reason: `${target} exists and is not a folder` };
|
||||
if ((await fs.readdir(real)).length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'EXISTS',
|
||||
reason: `${target} already has files in it. Use "Link Existing" for a project that already exists`,
|
||||
};
|
||||
}
|
||||
return { ok: true, path: real, existedEmpty: true };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, path: real, existedEmpty: false };
|
||||
return { ok: false, code: 'INVALID', reason: `Cannot read ${target}: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
+114
-1606
File diff suppressed because it is too large
Load Diff
+10
-520
@@ -1612,459 +1612,27 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window owns its PTY size (see
|
||||
// sendResize's detachedElsewhere guard in terminal-ui.js;
|
||||
// TerminalTile._sendResize() stands aside the same way), so Pane B could
|
||||
// only show it at a size it cannot set.
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
||||
// one into the picker put its detached window and Pane B in a fight over
|
||||
// the same PTY's dimensions.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||
// reading its tmux pane, and the split never does selectSession()'s
|
||||
// re-attach POST: Pane B would open a healthy-looking socket onto a pane
|
||||
// that nothing feeds and nothing reads.
|
||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||
// invisible — the healthy socket never trips the disconnect banner.
|
||||
if (session.pid === null) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
|
||||
//
|
||||
// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
|
||||
// 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
|
||||
// Desktop only, behind the same 1180px gate as the split pane.
|
||||
|
||||
/**
|
||||
* Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
|
||||
* docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
|
||||
* the headless frame bar and is untested on hardware. Everything that limits
|
||||
* the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
|
||||
* so raising the cap is this one line.
|
||||
*/
|
||||
const TILE_GRID_MAX = 6;
|
||||
/** The largest count the layout table covers (3x3). Never a cap by itself. */
|
||||
const TILE_LAYOUT_MAX = 9;
|
||||
// The smallest tile worth showing: about 60 columns and a dozen rows at the
|
||||
// default tile font. Bounds how many tiles a window can hold.
|
||||
const TILE_MIN_W = 480;
|
||||
const TILE_MIN_H = 240;
|
||||
// Three tiles go side by side (3x1) only when each still gets ~600px;
|
||||
// otherwise they take three cells of a 2x2.
|
||||
const TILE_GRID_WIDE_3X1 = 1800;
|
||||
// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
|
||||
// renderers at 50k lines each is a real memory cost, and a tile's load is a
|
||||
// bounded 1 MiB window anyway, so more scrollback only fills with live output.
|
||||
const TILE_SCROLLBACK = 10000;
|
||||
// Tiles have their own per-device font size (a tile is a fraction of the screen).
|
||||
const TILE_FONT_SIZE_DEFAULT = 13;
|
||||
// What the page's SSE filter names while tiles own the terminal: a value no
|
||||
// session id takes (ids are UUIDs), so the server, whose filter gates only
|
||||
// session:terminal batches, sends none. The tiles carry their own output over
|
||||
// their own sockets, and the parked main terminal only parsed those frames to
|
||||
// drop them (16 to 18 a second for one busy shell). Every other event still
|
||||
// arrives (test/sse-tile-grid-filter.test.ts pins the server's side of this).
|
||||
const TILE_GRID_SSE_FILTER = 'tile-grid';
|
||||
|
||||
/**
|
||||
* Columns and rows for `count` tiles, by count (the spec's table), and whether
|
||||
* that layout gives every cell at least the minimum tile size (TILE_MIN_W x
|
||||
* TILE_MIN_H) in a grid area of `width` x `height` px.
|
||||
*
|
||||
* @param {{count: number, width?: number, height?: number}} p
|
||||
* @returns {{cols: number, rows: number, fits: boolean}}
|
||||
*/
|
||||
function computeTileLayout({ count, width = Infinity, height = Infinity }) {
|
||||
const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
|
||||
let cols;
|
||||
let rows;
|
||||
if (n === 0) return { cols: 0, rows: 0, fits: true };
|
||||
if (n === 1) { cols = 1; rows = 1; }
|
||||
else if (n === 2) { cols = 2; rows = 1; }
|
||||
else if (n === 3) {
|
||||
if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; }
|
||||
else { cols = 2; rows = 2; }
|
||||
}
|
||||
else if (n === 4) { cols = 2; rows = 2; }
|
||||
else if (n <= 6) { cols = 3; rows = 2; }
|
||||
else { cols = 3; rows = 3; }
|
||||
const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H;
|
||||
return { cols, rows, fits };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX
|
||||
* whose layout, and every smaller count's layout, fits. 0 when not even one
|
||||
* tile fits.
|
||||
*
|
||||
* @param {{width: number, height: number}} p
|
||||
* @returns {number}
|
||||
*/
|
||||
function tileGridCapacity({ width, height }) {
|
||||
let capacity = 0;
|
||||
for (let n = 1; n <= TILE_GRID_MAX; n++) {
|
||||
if (!computeTileLayout({ count: n, width, height }).fits) break;
|
||||
capacity = n;
|
||||
}
|
||||
return capacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
|
||||
* deleted, detached and duplicate ids are dropped, the list is capped at
|
||||
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
|
||||
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
|
||||
* (or its JSON) gives null.
|
||||
*
|
||||
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
|
||||
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
|
||||
* with no hole. `ids` comes back packed (the tiles in reading order, what
|
||||
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
|
||||
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
|
||||
*
|
||||
* @param {unknown} raw - the parsed value, or the stored JSON string
|
||||
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
|
||||
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
||||
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
|
||||
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||
*/
|
||||
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
let value = raw;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { return null; }
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null;
|
||||
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
||||
const ids = [];
|
||||
const cells = [];
|
||||
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
||||
const keep =
|
||||
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
|
||||
ids.length < TILE_GRID_MAX;
|
||||
if (keep) ids.push(id);
|
||||
// A malformed entry (not a string, not null) is a hole too.
|
||||
cells.push(keep ? id : null);
|
||||
}
|
||||
const fractions = (fr) => {
|
||||
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
|
||||
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
|
||||
};
|
||||
return {
|
||||
v: 1,
|
||||
open: value.open === true && ids.length > 0,
|
||||
ids,
|
||||
cells,
|
||||
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
||||
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
||||
colFr: fractions(value.colFr),
|
||||
rowFr: fractions(value.rowFr),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* New track fractions after a divider drag (grid-template `fr` values): the two
|
||||
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
||||
* least `minPx` (or half the pair, if the pair cannot give both the minimum).
|
||||
* Every other track keeps its size. Computed from the fractions the drag
|
||||
* STARTED with and the pointer's total travel, so a drag never drifts.
|
||||
*
|
||||
* @param {number[]} fr - the fractions when the drag started
|
||||
* @param {number} index - the divider: between track `index` and `index + 1`
|
||||
* @param {number} deltaPx - pointer travel since the drag started
|
||||
* @param {number} totalPx - the size the tracks share (dividers and padding excluded)
|
||||
* @param {number} minPx - the smallest a track may get
|
||||
* @returns {number[]} new fractions, same length
|
||||
*/
|
||||
function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
|
||||
const out = fr.slice();
|
||||
if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
|
||||
const sum = fr.reduce((a, b) => a + b, 0);
|
||||
if (!(sum > 0)) return out;
|
||||
const a = (fr[index] / sum) * totalPx;
|
||||
const b = (fr[index + 1] / sum) * totalPx;
|
||||
const pair = a + b;
|
||||
const lo = Math.min(minPx, pair / 2);
|
||||
const hi = pair - lo;
|
||||
const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
|
||||
out[index] = (nextA / totalPx) * sum;
|
||||
out[index + 1] = ((pair - nextA) / totalPx) * sum;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
||||
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
|
||||
* its own window (that window owns its PTY size). A session with no PTY
|
||||
* attached IS offered: its tile shows the Attach overlay.
|
||||
*
|
||||
* @param {Map<string, {name?: string}>} sessions
|
||||
* @param {string[]} sessionOrder
|
||||
* @param {{has(id: string): boolean}} [detachedIds]
|
||||
* @returns {Array<{id: string, label: string}>}
|
||||
*/
|
||||
function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||||
* (owner decision 8). In order:
|
||||
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
||||
* detached, at most the cap), if any of its sessions survive;
|
||||
* b. else an open split's two sessions, Pane A focused;
|
||||
* c. else the open sessions in tab order (buildTilePickerSessions: no
|
||||
* detached ones), up to `limit`, the active session always among them and focused
|
||||
* (when it sits past the limit, the first `limit - 1` others come with it).
|
||||
* Null when there is nothing to open.
|
||||
*
|
||||
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
|
||||
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||||
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||||
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
|
||||
*/
|
||||
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
|
||||
if (stored?.ids?.length) {
|
||||
const focus = stored.zoomed || stored.focused;
|
||||
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
|
||||
}
|
||||
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||||
const pair = (split || []).filter(usable);
|
||||
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||||
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
||||
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
|
||||
if (all.length === 0) return null;
|
||||
let ids = all.slice(0, max);
|
||||
if (all.includes(activeId) && !ids.includes(activeId)) {
|
||||
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
||||
}
|
||||
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile counts the Tiles button's right-click menu offers, and the count a
|
||||
* click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md).
|
||||
*/
|
||||
const TILE_GRID_COUNTS = [2, 4, 6];
|
||||
const TILE_GRID_COUNT_DEFAULT = 6;
|
||||
|
||||
/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */
|
||||
function sanitizeTileCount(raw) {
|
||||
const n = Number(raw);
|
||||
return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* `base` (what the grid would open, or what an open grid shows, in its order)
|
||||
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
||||
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
||||
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
|
||||
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
|
||||
*
|
||||
* @param {string[]} base
|
||||
* @param {string[]} all
|
||||
* @param {number} n - at most TILE_GRID_MAX
|
||||
* @param {string|null} [keepId]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function tileGridSetForCount(base, all, n, keepId = null) {
|
||||
const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX));
|
||||
const ids = [];
|
||||
for (const id of [...(base || []), ...(all || [])]) {
|
||||
if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
// Every `base` id comes before every filler, so a trim never drops a base id
|
||||
// in favour of one.
|
||||
let out = ids.slice(0, max);
|
||||
if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which tile takes focus when `id` leaves the grid: the next one in grid
|
||||
* order, else the previous one, else null.
|
||||
*
|
||||
* @param {string[]} ids - the grid's tiles, in reading order
|
||||
* @param {string} id - the tile that is leaving
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function tileNeighbor(ids, id) {
|
||||
const i = ids.indexOf(id);
|
||||
if (i === -1) return ids[0] ?? null;
|
||||
return ids[i + 1] ?? ids[i - 1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile a directional focus chord moves to, in a row-major grid of `cols`
|
||||
* columns whose empty cells are `null` (a hole can be any cell) or simply
|
||||
* missing at the end. Focus never lands on a hole. Left and right go along
|
||||
* the row, past any hole, and never leave it. Up and down take the nearest
|
||||
* row in that direction that has a tile: the tile in the same column, else
|
||||
* the one in the nearest column (the lower column on a tie), so moving down
|
||||
* onto a short or holed last row lands on its nearest tile. Null when there
|
||||
* is no tile in that direction.
|
||||
*
|
||||
* @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles)
|
||||
* @param {string} focusedId - the tile the keyboard is in
|
||||
* @param {'left'|'right'|'up'|'down'} direction
|
||||
* @param {number} cols - the layout's column count
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function tileInDirection(cells, focusedId, direction, cols) {
|
||||
const i = focusedId ? cells.indexOf(focusedId) : -1;
|
||||
if (i === -1 || !(cols >= 1)) return null;
|
||||
const rows = Math.ceil(cells.length / cols);
|
||||
const row = Math.floor(i / cols);
|
||||
const col = i % cols;
|
||||
const at = (r, c) => cells[r * cols + c] || null;
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
const step = direction === 'left' ? -1 : 1;
|
||||
for (let c = col + step; c >= 0 && c < cols; c += step) {
|
||||
if (at(row, c)) return at(row, c);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (direction !== 'up' && direction !== 'down') return null;
|
||||
const step = direction === 'up' ? -1 : 1;
|
||||
for (let r = row + step; r >= 0 && r < rows; r += step) {
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const id = at(r, c);
|
||||
if (id && Math.abs(c - col) < bestDistance) {
|
||||
best = id;
|
||||
bestDistance = Math.abs(c - col);
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cell next to cell `index` in that direction (Move Tile: a tile moves
|
||||
* into an empty neighbour cell, or swaps with a tiled one), or -1 at the
|
||||
* edge. Adjacent only: a move never jumps over a cell.
|
||||
*
|
||||
* @param {number} index - the cell, in reading order
|
||||
* @param {'left'|'right'|'up'|'down'} direction
|
||||
* @param {number} cols - the layout's column count
|
||||
* @param {number} cellCount - cols x rows
|
||||
* @returns {number}
|
||||
*/
|
||||
function tileCellInDirection(index, direction, cols, cellCount) {
|
||||
if (!(cols >= 1) || index < 0 || index >= cellCount) return -1;
|
||||
const col = index % cols;
|
||||
let j = -1;
|
||||
if (direction === 'left') j = col > 0 ? index - 1 : -1;
|
||||
else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1;
|
||||
else if (direction === 'up') j = index - cols;
|
||||
else if (direction === 'down') j = index + cols;
|
||||
return j >= 0 && j < cellCount ? j : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid's cells after its shape changed (or to fill one for the first
|
||||
* time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps
|
||||
* every cell as it is. A new shape keeps each tile at its row and column when
|
||||
* every tile still fits there (2x2 growing to 3x2: the four tiles stay put),
|
||||
* and otherwise packs the tiles in reading order from the first cell, holes
|
||||
* collapsed (positions do not map between shapes). `oldCols` 0 (no layout
|
||||
* yet) always packs.
|
||||
*
|
||||
* @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide
|
||||
* @param {number} oldCols - the column count they were laid out with
|
||||
* @param {number} cols
|
||||
* @param {number} rows
|
||||
* @returns {(string|null)[]}
|
||||
*/
|
||||
function fitTileCells(cells, oldCols, cols, rows) {
|
||||
const size = Math.max(0, cols * rows);
|
||||
if (oldCols === cols && cells.length === size) return cells.slice();
|
||||
const out = new Array(size).fill(null);
|
||||
const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null));
|
||||
const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols));
|
||||
if (keep) {
|
||||
for (const p of placed) if (p) out[p.row * cols + p.col] = p.id;
|
||||
return out;
|
||||
}
|
||||
cells.filter(Boolean).slice(0, size).forEach((id, k) => {
|
||||
out[k] = id;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns a grid of `length` cells was laid out with: stored cells
|
||||
* carry no shape of their own, and the layout table gives each cell count one
|
||||
* shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other
|
||||
* length (fitTileCells then packs).
|
||||
*
|
||||
* @param {number} length
|
||||
* @returns {number}
|
||||
*/
|
||||
function tileCellCols(length) {
|
||||
// Every cell count is some count's shape on a wide grid area (2x2, the
|
||||
// narrow 3-tile shape, is also the 4-tile one).
|
||||
for (let n = 1; n <= TILE_LAYOUT_MAX; n++) {
|
||||
const { cols, rows } = computeTileLayout({ count: n });
|
||||
if (cols * rows === length) return cols;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cells of a grid re-formed to another set of tiles (a count picked in the
|
||||
* Tiles menu, or the Tiles button bringing back a remembered grid with more or
|
||||
* fewer tiles): the tiles in `keep` stay in their cells and every other cell
|
||||
* empties, then the cell model's shape rule (fitTileCells: each tile keeps its
|
||||
* row and column when all fit, else they pack in reading order), then the
|
||||
* tiles in `add` fill the empty cells in reading order, holes first.
|
||||
*
|
||||
* @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide
|
||||
* @param {number} oldCols
|
||||
* @param {string[]} keep - tiles that stay
|
||||
* @param {string[]} add - tiles that join, in the order they fill
|
||||
* @param {number} cols - the new shape
|
||||
* @param {number} rows
|
||||
* @returns {(string|null)[]}
|
||||
*/
|
||||
function reformTileCells(cells, oldCols, keep, add, cols, rows) {
|
||||
const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null));
|
||||
const out = fitTileCells(kept, oldCols, cols, rows);
|
||||
for (const id of add) {
|
||||
if (!id || out.includes(id)) continue;
|
||||
const k = out.indexOf(null);
|
||||
// Not for a shape made for the count; a full grid takes no more.
|
||||
if (k === -1) break;
|
||||
out[k] = id;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the
|
||||
* grid is open: tiles cycle in reading order and wrap.
|
||||
*
|
||||
* @param {string[]} ids
|
||||
* @param {string} focusedId
|
||||
* @param {number} delta - +1 or -1
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function cycleTile(ids, focusedId, delta) {
|
||||
if (ids.length === 0) return null;
|
||||
const i = ids.indexOf(focusedId);
|
||||
if (i === -1) return ids[0];
|
||||
return ids[(i + delta + ids.length) % ids.length];
|
||||
}
|
||||
|
||||
// ── Renderer liveness ──────────────────────────────────────────────────────
|
||||
//
|
||||
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||
@@ -2301,59 +1869,7 @@ function sessionIdFromFragment(hash) {
|
||||
return id && id.trim() ? id.trim() : null;
|
||||
}
|
||||
|
||||
/** Longest model name a session header shows (the server caps it as well). */
|
||||
const SESSION_MODEL_MAX_CHARS = 64;
|
||||
/** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
|
||||
const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
|
||||
|
||||
/**
|
||||
* What a session's header says about its harness: the CLI id (the
|
||||
* `run-mode-dot <id>` logo class), the registry's label for it, the model the
|
||||
* session runs when the server knows it (`SessionState.displayModel`), and the
|
||||
* tooltip naming both.
|
||||
*
|
||||
* The id is data: the label comes from the injected CLI catalog and falls back
|
||||
* to the id, so a CLI added through clis.json still gets a name. The model is
|
||||
* untrusted text (read off a pane, or a CLI's own report): control characters
|
||||
* are dropped and the length capped here too, and callers render it with
|
||||
* textContent. The tooltip says where a model that is not the CLI's own report
|
||||
* came from, so it never claims more than the server knows: one the session
|
||||
* was launched with may have been switched since, one read from the CLI's
|
||||
* config is what it is configured to run, and a custom endpoint's model is the
|
||||
* endpoint's, whatever the CLI calls it.
|
||||
*
|
||||
* @param {object} session - a session from app.sessions
|
||||
* @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
|
||||
* @returns {{id: string, label: string, model: string, title: string}}
|
||||
*/
|
||||
function describeSessionHarness(session, catalog) {
|
||||
const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
|
||||
const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
|
||||
const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
|
||||
const raw = session?.displayModel?.model;
|
||||
const model =
|
||||
typeof raw === 'string'
|
||||
? raw
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
||||
.trim()
|
||||
.slice(0, SESSION_MODEL_MAX_CHARS)
|
||||
: '';
|
||||
const source = session?.displayModel?.source;
|
||||
const qualifier = !model
|
||||
? ''
|
||||
: source === 'launch'
|
||||
? ' (set at launch)'
|
||||
: source === 'custom-endpoint'
|
||||
? ' (custom endpoint)'
|
||||
: source === 'config'
|
||||
? ' (from config)'
|
||||
: '';
|
||||
const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
|
||||
return { id, label, model, title };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
@@ -2363,32 +1879,6 @@ if (typeof window !== 'undefined') {
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
window.CodemanTileGrid = {
|
||||
computeTileLayout,
|
||||
tileGridCapacity,
|
||||
sanitizeTileGridState,
|
||||
buildTilePickerSessions,
|
||||
dragTrackFractions,
|
||||
tileNeighbor,
|
||||
tileInDirection,
|
||||
tileCellInDirection,
|
||||
fitTileCells,
|
||||
cycleTile,
|
||||
tileGridOpenSet,
|
||||
sanitizeTileCount,
|
||||
tileGridSetForCount,
|
||||
tileCellCols,
|
||||
reformTileCells,
|
||||
TILE_GRID_COUNTS,
|
||||
TILE_GRID_COUNT_DEFAULT,
|
||||
TILE_GRID_MAX,
|
||||
TILE_LAYOUT_MAX,
|
||||
TILE_MIN_W,
|
||||
TILE_MIN_H,
|
||||
TILE_SCROLLBACK,
|
||||
TILE_FONT_SIZE_DEFAULT,
|
||||
TILE_GRID_SSE_FILTER,
|
||||
};
|
||||
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
|
||||
window.CodemanFetchDeadline = {
|
||||
terminalFetchDeadlineMs,
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
|
||||
*
|
||||
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
|
||||
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
|
||||
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
|
||||
* Files window listing exactly which files are uncommitted and which commits are not pushed.
|
||||
*
|
||||
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
|
||||
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
|
||||
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
|
||||
* session changes or the window regains focus). The route is read-only and offline: it never fetches
|
||||
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
|
||||
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
|
||||
*
|
||||
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
|
||||
* only ever written with `textContent`, never `innerHTML`.
|
||||
*
|
||||
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
|
||||
* tablet only by construction.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||
* @dependency panels-ui.js (openFilePreview)
|
||||
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** How often the active session's repository is re-read while the indicator is on. */
|
||||
const GIT_STATUS_POLL_MS = 15000;
|
||||
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
|
||||
const GIT_STATUS_TICK_MS = 2000;
|
||||
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
|
||||
const GIT_STATUS_MIN_REFRESH_MS = 3000;
|
||||
|
||||
const GIT_STATUS_BADGE_TITLE = {
|
||||
M: 'Modified',
|
||||
A: 'Added',
|
||||
D: 'Deleted',
|
||||
R: 'Renamed',
|
||||
C: 'Copied',
|
||||
T: 'Type changed',
|
||||
U: 'Unmerged',
|
||||
'?': 'Untracked',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isGitStatusEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
|
||||
* runs on boot and after every settings save, so a live toggle needs no reload.
|
||||
*/
|
||||
applyGitStatusVisibility() {
|
||||
const on = this.isGitStatusEnabled();
|
||||
if (on && !this._gitStatusTimer) {
|
||||
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
|
||||
this._gitStatusOnVisible = () => {
|
||||
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
|
||||
};
|
||||
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||
window.addEventListener('focus', this._gitStatusOnVisible);
|
||||
this.refreshGitStatus();
|
||||
} else if (!on && this._gitStatusTimer) {
|
||||
clearInterval(this._gitStatusTimer);
|
||||
this._gitStatusTimer = null;
|
||||
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||
window.removeEventListener('focus', this._gitStatusOnVisible);
|
||||
this._gitStatusOnVisible = null;
|
||||
}
|
||||
if (!on) {
|
||||
this._gitStatus = null;
|
||||
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
|
||||
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
|
||||
// turning the setting back on would skip every refresh for this session until a reload.
|
||||
this._gitStatusInFlight = false;
|
||||
this.closeGitStatusPanel();
|
||||
}
|
||||
this._renderGitStatusButton();
|
||||
},
|
||||
|
||||
_gitStatusTick() {
|
||||
if (document.hidden) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
if (sid !== this._gitStatusSessionId) {
|
||||
// The active session changed (or the first one opened): show nothing stale, read now.
|
||||
this._gitStatus = null;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
|
||||
this.refreshGitStatus();
|
||||
return;
|
||||
}
|
||||
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
|
||||
},
|
||||
|
||||
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
|
||||
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
|
||||
if (!this.isGitStatusEnabled()) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
this._gitStatusSessionId = sid;
|
||||
if (!sid) {
|
||||
this._gitStatus = null;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||
return;
|
||||
}
|
||||
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
|
||||
// A read for THIS session is already running: let it finish. One for another session is not worth
|
||||
// waiting for (its answer is dropped below), so a session switch is never left blank.
|
||||
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
|
||||
this._gitStatusInFlight = true;
|
||||
this._gitStatusInFlightSid = sid;
|
||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||
this._gitStatusFetchedAt = Date.now();
|
||||
try {
|
||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||
} catch {
|
||||
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
|
||||
} finally {
|
||||
// Only the newest request owns the flag: an older one finishing late must not clear it.
|
||||
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
|
||||
}
|
||||
if (epoch !== this._gitStatusEpoch) return;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||
isGitStatusTree() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.gitStatusTree ?? defaults.gitStatusTree ?? true) === true;
|
||||
},
|
||||
|
||||
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
|
||||
_currentGitStatus() {
|
||||
const s = this._gitStatus;
|
||||
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
|
||||
},
|
||||
|
||||
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
|
||||
_gitStatusSummary(overview) {
|
||||
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
|
||||
let uncommitted = 0;
|
||||
let unpushed = 0;
|
||||
let conflicted = 0;
|
||||
for (const r of overview.repos) {
|
||||
uncommitted += r.status.counts.uncommitted;
|
||||
unpushed += r.status.unpushedCount;
|
||||
conflicted += r.status.counts.conflicted;
|
||||
}
|
||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
||||
},
|
||||
|
||||
/** One sentence for the tooltip and the screen-reader label. */
|
||||
_gitStatusSentence(overview) {
|
||||
const sum = this._gitStatusSummary(overview);
|
||||
if (!sum) return '';
|
||||
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
||||
const bits = [];
|
||||
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
||||
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
||||
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||
if (!bits.length) bits.push('everything is committed and pushed');
|
||||
let where;
|
||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||
else {
|
||||
const d = overview.repos[0].status;
|
||||
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||
}
|
||||
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||
},
|
||||
|
||||
_renderGitStatusButton() {
|
||||
const btn = this.$('gitStatusBtn');
|
||||
if (!btn) return;
|
||||
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
|
||||
const sum = this._gitStatusSummary(data);
|
||||
btn.hidden = !sum;
|
||||
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
|
||||
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
|
||||
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
|
||||
const label = btn.querySelector('.git-status-label');
|
||||
if (!sum) {
|
||||
if (label) label.textContent = '';
|
||||
return;
|
||||
}
|
||||
const parts = [];
|
||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||
if (!parts.length) parts.push('✓');
|
||||
if (label) label.textContent = parts.join(' ');
|
||||
const sentence = this._gitStatusSentence(data);
|
||||
btn.title = sentence;
|
||||
btn.setAttribute('aria-label', sentence);
|
||||
},
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_isGitStatusPanelOpen() {
|
||||
return !!this.$('gitStatusPanel')?.classList.contains('visible');
|
||||
},
|
||||
|
||||
toggleGitStatusPanel() {
|
||||
if (this._isGitStatusPanelOpen()) {
|
||||
this.closeGitStatusPanel();
|
||||
return;
|
||||
}
|
||||
const panel = this.$('gitStatusPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
|
||||
this._ensureGitStatusPanelDrag();
|
||||
this._renderGitStatusPanel();
|
||||
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
|
||||
},
|
||||
|
||||
closeGitStatusPanel() {
|
||||
this._gitDiffView = null;
|
||||
const panel = this.$('gitStatusPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset a dragged position so it reopens at the default spot.
|
||||
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||
}
|
||||
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
refreshGitStatusNow() {
|
||||
this._gitStatusFetchedAt = 0;
|
||||
this._gitStatusInFlight = false;
|
||||
return this.refreshGitStatus({ fresh: true });
|
||||
},
|
||||
|
||||
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
|
||||
_ensureGitStatusPanelDrag() {
|
||||
const panel = this.$('gitStatusPanel');
|
||||
const handle = panel?.querySelector('.git-status-header');
|
||||
if (!panel || !handle || handle._dragReady) return;
|
||||
handle._dragReady = true;
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||
panel.style.left = `${rect.left}px`;
|
||||
panel.style.top = `${rect.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
handle.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||
});
|
||||
const end = (e) => {
|
||||
drag = null;
|
||||
handle.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
},
|
||||
|
||||
_gitEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
_renderGitStatusPanel() {
|
||||
const body = this.$('gitStatusBody');
|
||||
const head = this.$('gitStatusBranch');
|
||||
const foot = this.$('gitStatusFooter');
|
||||
if (!body) return;
|
||||
const overview = this._currentGitStatus();
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
|
||||
const focusKey = body.contains(document.activeElement)
|
||||
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
|
||||
: null;
|
||||
const view = this._gitDiffView;
|
||||
if (view && view.sessionId === this.activeSessionId) {
|
||||
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
|
||||
this._renderGitDiffView(body, view);
|
||||
if (head) head.textContent = '';
|
||||
if (foot) foot.textContent = '';
|
||||
return;
|
||||
}
|
||||
this._gitDiffView = null;
|
||||
body.replaceChildren();
|
||||
const clearChrome = () => {
|
||||
if (head) head.textContent = '';
|
||||
if (foot) foot.textContent = '';
|
||||
};
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
|
||||
clearChrome();
|
||||
return;
|
||||
}
|
||||
if (!overview) {
|
||||
body.append(
|
||||
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (overview.state !== 'ok') {
|
||||
const why =
|
||||
overview.state === 'not-a-repo'
|
||||
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
|
||||
: overview.state === 'unsupported'
|
||||
? overview.reason === 'docker'
|
||||
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||
: 'Git status is not available for remote (SSH) sessions.'
|
||||
: `Could not read the repository: ${overview.error || 'git failed'}`;
|
||||
body.append(el('div', 'git-status-empty', why));
|
||||
clearChrome();
|
||||
return;
|
||||
}
|
||||
|
||||
const repos = overview.repos;
|
||||
if (repos.length === 1) {
|
||||
// One repository: the panel is that repository, as it always was.
|
||||
const d = repos[0].status;
|
||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||
this._renderGitRepoInto(body, d);
|
||||
} else {
|
||||
if (head) head.textContent = `${repos.length} repositories`;
|
||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||
if (overview.reposTruncated) {
|
||||
body.append(
|
||||
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (foot) {
|
||||
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
|
||||
}
|
||||
if (focusKey) {
|
||||
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
|
||||
again?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* One repository of several: a collapsible section, collapsed by default (the summary line already
|
||||
* shows what is outstanding). Which ones the user opened stay open across the 15 s re-render.
|
||||
*/
|
||||
_gitRepoSection(r) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const d = r.status;
|
||||
const section = el('details', 'git-status-repo');
|
||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||
const repoKey = `repo|${d.repoRoot || r.path}`;
|
||||
section.open = openRepos.has(repoKey);
|
||||
section.addEventListener('toggle', () => (section.open ? openRepos.add(repoKey) : openRepos.delete(repoKey)));
|
||||
const summary = el('summary', 'git-status-repo-summary');
|
||||
summary.append(el('span', 'git-status-repo-name', r.name));
|
||||
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
|
||||
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
|
||||
const bits = [];
|
||||
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
|
||||
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
|
||||
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
|
||||
const state = el(
|
||||
'span',
|
||||
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
|
||||
bits.join(' ') || '✓'
|
||||
);
|
||||
summary.append(state);
|
||||
section.append(summary);
|
||||
const inner = el('div', 'git-status-repo-body');
|
||||
this._renderGitRepoInto(inner, d);
|
||||
section.append(inner);
|
||||
return section;
|
||||
},
|
||||
|
||||
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
|
||||
_renderGitRepoInto(body, data) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
|
||||
// Branch / upstream line.
|
||||
const line = el('div', 'git-status-branchline');
|
||||
if (data.upstream && data.upstreamGone) {
|
||||
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
|
||||
gone.title =
|
||||
'The upstream branch does not exist on the remote (never pushed, or deleted and pruned), so the commits below are on no remote.';
|
||||
line.append(gone);
|
||||
} else if (data.upstream) {
|
||||
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
|
||||
if (data.behind) {
|
||||
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
|
||||
behind.title = 'As of the last git fetch: Codeman never fetches.';
|
||||
line.append(behind);
|
||||
}
|
||||
} else if (data.hasRemote) {
|
||||
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
|
||||
} else {
|
||||
line.append(el('span', 'git-status-chip', 'No remote configured'));
|
||||
}
|
||||
if (data.counts.stashes) {
|
||||
line.append(
|
||||
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
|
||||
);
|
||||
}
|
||||
body.append(line);
|
||||
|
||||
// Uncommitted changes.
|
||||
const filesSection = el('section', 'git-status-section');
|
||||
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
|
||||
if (!data.files.length) {
|
||||
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
|
||||
} else {
|
||||
const groups = [
|
||||
['conflicted', 'Merge conflicts'],
|
||||
['staged', 'Staged'],
|
||||
['unstaged', 'Not staged'],
|
||||
['untracked', 'Untracked'],
|
||||
];
|
||||
for (const [kind, label] of groups) {
|
||||
const rows = data.files.filter((f) => f.kind === kind);
|
||||
if (!rows.length) continue;
|
||||
const group = el('div', `git-status-group git-status-group--${kind}`);
|
||||
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`));
|
||||
if (this.isGitStatusTree()) group.append(...this._gitFileTree(rows, data, kind));
|
||||
else for (const f of rows) group.append(this._gitFileRow(f, data));
|
||||
filesSection.append(group);
|
||||
}
|
||||
if (data.filesTruncated) {
|
||||
filesSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-more',
|
||||
`Showing the first ${data.files.length} entries; the counts above include every file.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
body.append(filesSection);
|
||||
|
||||
// Commits not pushed.
|
||||
const pushSection = el('section', 'git-status-section');
|
||||
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
|
||||
if (!data.unpushedCount) {
|
||||
pushSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-ok',
|
||||
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if (!data.upstream || data.upstreamGone) {
|
||||
pushSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-note',
|
||||
data.upstreamGone
|
||||
? 'The upstream branch does not exist on the remote (never pushed, or deleted), so these commits are on no remote.'
|
||||
: 'This branch has no upstream, so these commits are on no remote yet.'
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
|
||||
if (data.unpushedCount > data.unpushed.length) {
|
||||
pushSection.append(
|
||||
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
body.append(pushSection);
|
||||
},
|
||||
|
||||
/**
|
||||
* `rows` as folders (collapsed until clicked) holding their files. A folder with one child folder and
|
||||
* nothing else is merged into it (`src/web/public` as one row) so a deep path is one click, not five.
|
||||
* Which folders are open survives the 15 s re-render (`_gitTreeOpen`, keyed by repo, group and folder).
|
||||
*/
|
||||
_gitFileTree(rows, data, kind) {
|
||||
const root = { dirs: new Map(), files: [] };
|
||||
for (const f of rows) {
|
||||
const trailing = f.path.endsWith('/');
|
||||
const parts = f.path.replace(/\/$/, '').split('/');
|
||||
const leaf = parts.pop() + (trailing ? '/' : '');
|
||||
let node = root;
|
||||
for (const part of parts) {
|
||||
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
|
||||
node = node.dirs.get(part);
|
||||
}
|
||||
node.files.push({ f, leaf });
|
||||
}
|
||||
const open = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||
const count = (n) => n.files.length + [...n.dirs.values()].reduce((sum, d) => sum + count(d), 0);
|
||||
const build = (node, prefix) => {
|
||||
const out = [];
|
||||
for (const [name0, child0] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
let name = name0;
|
||||
let child = child0;
|
||||
while (child.files.length === 0 && child.dirs.size === 1) {
|
||||
const [n, c] = [...child.dirs][0];
|
||||
name += `/${n}`;
|
||||
child = c;
|
||||
}
|
||||
const key = `${data.repoRoot}|${kind}|${prefix}${name}`;
|
||||
const dir = this._gitEl('details', 'git-tree-dir');
|
||||
dir.open = open.has(key);
|
||||
dir.addEventListener('toggle', () => (dir.open ? open.add(key) : open.delete(key)));
|
||||
const summary = this._gitEl('summary', 'git-tree-summary');
|
||||
summary.append(this._gitEl('span', 'git-tree-name', `${name}/`));
|
||||
summary.append(this._gitEl('span', 'git-tree-count', String(count(child))));
|
||||
dir.append(summary);
|
||||
const inner = this._gitEl('div', 'git-tree-children');
|
||||
inner.append(...build(child, `${prefix}${name}/`));
|
||||
dir.append(inner);
|
||||
out.push(dir);
|
||||
}
|
||||
for (const { f, leaf } of node.files.sort((a, b) => a.leaf.localeCompare(b.leaf))) {
|
||||
out.push(this._gitFileRow(f, data, leaf));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return build(root, '');
|
||||
},
|
||||
|
||||
_gitFileRow(f, data, displayName) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const row = el('div', 'git-status-file');
|
||||
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
|
||||
const letter =
|
||||
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
|
||||
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
|
||||
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
|
||||
row.dataset.gitKey = `${f.kind}|${f.path}`;
|
||||
row.append(badge);
|
||||
const name = el('span', 'git-status-path', displayName ?? f.path);
|
||||
if (displayName) name.title = f.path;
|
||||
row.append(name);
|
||||
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
|
||||
|
||||
// An untracked folder has no single diff; every other row opens its changes.
|
||||
if (!f.path.endsWith('/') && data.repoRoot) {
|
||||
row.classList.add('git-status-file--clickable');
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.title = 'Show what changed';
|
||||
const open = () => this.openGitDiff(data.repoRoot, f, letter);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
return row;
|
||||
},
|
||||
|
||||
// ── Diff view ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Show `file`'s changes in the panel (a Back button returns to the list). */
|
||||
async openGitDiff(repoRoot, file, letter) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||
this._gitDiffView = view;
|
||||
this._renderGitStatusPanel();
|
||||
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
|
||||
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
||||
// Back, another file or another session while this was in flight: drop the answer.
|
||||
if (this._gitDiffView !== view) return;
|
||||
let body = null;
|
||||
try {
|
||||
body = res ? await res.json() : null;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (this._gitDiffView !== view) return;
|
||||
if (res && res.ok && body?.success) {
|
||||
view.state = 'ok';
|
||||
view.result = body.data;
|
||||
} else {
|
||||
view.state = 'error';
|
||||
view.error = body?.error || 'Could not read the diff.';
|
||||
}
|
||||
this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
closeGitDiff() {
|
||||
this._gitDiffView = null;
|
||||
this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
_renderGitDiffView(body, view) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
body.replaceChildren();
|
||||
const bar = el('div', 'git-diff-bar');
|
||||
const back = el('button', 'btn-toolbar btn-sm', '← Back');
|
||||
back.type = 'button';
|
||||
back.addEventListener('click', () => this.closeGitDiff());
|
||||
bar.append(back);
|
||||
bar.append(el('span', 'git-diff-path', view.file.path));
|
||||
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
|
||||
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
|
||||
if (view.letter !== 'D') {
|
||||
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
|
||||
open.type = 'button';
|
||||
open.addEventListener('click', () =>
|
||||
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
|
||||
);
|
||||
bar.append(open);
|
||||
}
|
||||
body.append(bar);
|
||||
|
||||
if (view.state === 'loading') {
|
||||
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
|
||||
return;
|
||||
}
|
||||
if (view.state === 'error') {
|
||||
body.append(el('div', 'git-status-empty', view.error));
|
||||
return;
|
||||
}
|
||||
const { diff, truncated, binary } = view.result;
|
||||
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
|
||||
if (!diff.trim()) {
|
||||
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
|
||||
return;
|
||||
}
|
||||
const pre = el('pre', 'git-diff');
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const line of diff.split('\n')) {
|
||||
let cls = 'git-diff-line';
|
||||
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
|
||||
else if (
|
||||
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
|
||||
)
|
||||
cls += ' git-diff-line--meta';
|
||||
else if (line.startsWith('+')) cls += ' git-diff-line--add';
|
||||
else if (line.startsWith('-')) cls += ' git-diff-line--del';
|
||||
frag.append(el('span', cls, line + '\n'));
|
||||
}
|
||||
pre.append(frag);
|
||||
body.append(pre);
|
||||
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
|
||||
},
|
||||
|
||||
_gitCommitRow(c) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const row = el('div', 'git-status-commit');
|
||||
row.append(el('span', 'git-status-hash', c.hash));
|
||||
row.append(el('span', 'git-status-subject', c.subject));
|
||||
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
|
||||
row.append(el('span', 'git-status-commit-meta', meta));
|
||||
return row;
|
||||
},
|
||||
});
|
||||
@@ -67,25 +67,6 @@
|
||||
'Open away digest': '打开离开期间摘要',
|
||||
'Session Manager': '会话管理器',
|
||||
'Session actions': '会话操作',
|
||||
Ungrouped: '未分组',
|
||||
'Group actions': '分组操作',
|
||||
'Group name': '分组名称',
|
||||
'Web tab actions': '网页标签操作',
|
||||
'Web tab settings': '网页标签设置',
|
||||
'New group': '新建分组',
|
||||
'Rename group': '重命名分组',
|
||||
'Move group up': '上移分组',
|
||||
'Move group down': '下移分组',
|
||||
'Delete group': '删除分组',
|
||||
'Move up': '上移',
|
||||
'Move down': '下移',
|
||||
'Move to Ungrouped': '移到未分组',
|
||||
'Move to new group': '移到新分组',
|
||||
'Could not save tab groups.': '无法保存标签分组。',
|
||||
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
|
||||
'Your tab group edit was not saved.': '你的标签分组编辑未保存。',
|
||||
'Open session manager': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
@@ -96,80 +77,6 @@
|
||||
'Split: close the second session': '分屏:关闭第二个会话',
|
||||
'Close split': '关闭分屏',
|
||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
|
||||
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
|
||||
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
|
||||
// Counts, exit codes and durations are patterns in translateDynamic.
|
||||
Tiles: '平铺',
|
||||
Split: '分屏',
|
||||
'Tiled sessions': '平铺的会话',
|
||||
'Tiles: show several sessions side by side (right-click for how many)':
|
||||
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
|
||||
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
|
||||
'How many tiles': '窗格数量',
|
||||
// The Tiles button's hover card (the count and the fits note are patterns).
|
||||
'Click: open the grid': '单击:打开平铺网格',
|
||||
'Click: close the grid': '单击:关闭平铺网格',
|
||||
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
|
||||
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
|
||||
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
|
||||
'Toggle Tile Grid': '切换平铺网格',
|
||||
'Focus Tile Left': '聚焦左侧窗格',
|
||||
'Focus Tile Right': '聚焦右侧窗格',
|
||||
'Focus Tile Up': '聚焦上方窗格',
|
||||
'Focus Tile Down': '聚焦下方窗格',
|
||||
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
|
||||
'Move Tile Left': '向左移动窗格',
|
||||
'Move Tile Right': '向右移动窗格',
|
||||
'Move Tile Up': '向上移动窗格',
|
||||
'Move Tile Down': '向下移动窗格',
|
||||
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
|
||||
Drag: '拖动',
|
||||
"a tile's header": '窗格的标题栏',
|
||||
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||
'Remove Focused Tile': '移除聚焦的窗格',
|
||||
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||
'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
|
||||
'a tab': '标签页',
|
||||
'the Tiles button': '平铺按钮',
|
||||
Click: '单击',
|
||||
'Right-click': '右键单击',
|
||||
Arrows: '方向键',
|
||||
'not bound': '未绑定',
|
||||
'Open group as tiles': '以平铺方式打开分组',
|
||||
'No sessions to show as tiles': '没有可平铺显示的会话',
|
||||
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
|
||||
'Zoom this tile': '放大此窗格',
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
'Resize tile rows': '调整窗格行高',
|
||||
Attach: '附加',
|
||||
'Attaching…': '正在附加…',
|
||||
'Not attached': '未附加',
|
||||
'The session ended': '会话已结束',
|
||||
'The agent exited': '智能体已退出',
|
||||
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
|
||||
'Could not attach the session': '无法附加会话',
|
||||
// The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
|
||||
// its exit-code forms and the tab's accessible name are patterns.
|
||||
exited: '已退出',
|
||||
// The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ...
|
||||
// are a pattern; mode codes and product names stay), and the toolbar beside it.
|
||||
'Terminal / Shell': '终端 / Shell',
|
||||
'Send Enter': '发送回车',
|
||||
// The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse
|
||||
// input like Click (单击).
|
||||
Tabs: '标签页',
|
||||
'Toggle Session Sidebar': '切换会话侧边栏',
|
||||
'Copy Selection': '复制选中内容',
|
||||
'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)',
|
||||
'Focus Tabs': '聚焦标签页',
|
||||
Wheel: '滚轮',
|
||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||
Notifications: '通知',
|
||||
@@ -862,22 +769,6 @@
|
||||
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
|
||||
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
|
||||
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
|
||||
'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.':
|
||||
'仅允许字母、数字、连字符和下划线;将在下方的父文件夹中创建。',
|
||||
'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.':
|
||||
'在 ~/codeman-cases 下新建工作区,并生成独立的 CLAUDE.md。',
|
||||
'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.':
|
||||
'在你选择的文件夹中新建工作区,并生成独立的 CLAUDE.md。',
|
||||
'Create in a custom folder': '在自定义文件夹中创建',
|
||||
'📁 Create in a custom folder': '📁 在自定义文件夹中创建',
|
||||
'By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.':
|
||||
'新案例默认创建在 ~/codeman-cases 下。选择其他文件夹后,案例会改为创建在那里,并像其他案例一样列出。',
|
||||
'Parent Folder': '父文件夹',
|
||||
'Pick the folder the new case folder should be created inside.': '选择要在其中创建新案例文件夹的文件夹。',
|
||||
'Choose the folder to create the case in': '选择要在其中创建案例的文件夹',
|
||||
'Not available for a Docker case': 'Docker 案例不可用',
|
||||
'Not available with a custom folder': '使用自定义文件夹时不可用',
|
||||
'Browse…': '浏览…',
|
||||
'Docker exports': 'Docker 导出',
|
||||
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
|
||||
'Runs inside an isolated container. Multiple sessions can share the same container.':
|
||||
@@ -1025,17 +916,6 @@
|
||||
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
||||
}
|
||||
|
||||
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
|
||||
const TILE_STATE_ZH = {
|
||||
'needs you': '需要你',
|
||||
error: '错误',
|
||||
waiting: '等待中',
|
||||
working: '工作中',
|
||||
idle: '空闲',
|
||||
done: '已完成',
|
||||
exited: '已退出',
|
||||
};
|
||||
|
||||
function translateDynamic(source) {
|
||||
const patterns = [
|
||||
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
||||
@@ -1053,78 +933,6 @@
|
||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
[
|
||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||
],
|
||||
// Tile grid: counts, exit codes and durations pass through.
|
||||
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
|
||||
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
|
||||
[
|
||||
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
|
||||
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
|
||||
],
|
||||
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
|
||||
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
|
||||
[
|
||||
/^The grid already holds what this window fits \((\d+)\)$/,
|
||||
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
|
||||
],
|
||||
[
|
||||
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
|
||||
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
|
||||
],
|
||||
[
|
||||
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
|
||||
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
|
||||
],
|
||||
[
|
||||
/^The window is too small for (\d+) tiles: showing the focused one$/,
|
||||
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||
],
|
||||
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
|
||||
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
|
||||
// and where the model came from when the CLI did not report it. The harness
|
||||
// and model names pass through untranslated.
|
||||
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
|
||||
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
|
||||
[/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`],
|
||||
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
|
||||
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
|
||||
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
|
||||
// The tab's exited-agent badge, and the tab's accessible name carrying it.
|
||||
// The session name is user text: it passes through untranslated.
|
||||
[/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
|
||||
[/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
|
||||
[
|
||||
/^(.+) session, agent exited \(signal (\d+)\)$/,
|
||||
(_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
|
||||
],
|
||||
[/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
|
||||
[/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
|
||||
// A session name is user text: it passes through untranslated.
|
||||
[
|
||||
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
|
||||
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
|
||||
],
|
||||
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
|
||||
// is required: bare state words stay out of the table, they collide with
|
||||
// state strings on other surfaces (see mobile-overview.js).
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
|
||||
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
|
||||
],
|
||||
// The same while tiles can move, with the drag hint on a second line.
|
||||
// Anchored on the hint, so a bare state word is safe here.
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||
(_m, state, duration) =>
|
||||
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
const match = source.match(pattern);
|
||||
@@ -1183,23 +991,6 @@
|
||||
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
||||
}
|
||||
|
||||
// xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
|
||||
// a pane changes: thousands of mutation records a second with a grid of tiles,
|
||||
// each paying a closest() over the whole skip list. All rows of one terminal
|
||||
// share that parent, so its own shouldSkip() verdict is kept once it says
|
||||
// skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside
|
||||
// its `.xterm`. A rows container that is not skipped is never kept: its rows
|
||||
// go through the full check below like any other node.
|
||||
const skippedRows = new WeakSet();
|
||||
function isSkippedRow(node) {
|
||||
const rows = node.parentNode;
|
||||
if (!rows?.classList?.contains('xterm-rows')) return false;
|
||||
if (skippedRows.has(rows)) return true;
|
||||
if (!shouldSkip(rows)) return false;
|
||||
skippedRows.add(rows);
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSkipText(node) {
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
||||
@@ -1295,13 +1086,6 @@
|
||||
observer = new MutationObserver((mutations) => {
|
||||
if (applying) return;
|
||||
for (const mutation of mutations) {
|
||||
// A change inside a skipped surface cannot need translating: every
|
||||
// node it adds or edits sits under the same skip ancestor, so both
|
||||
// translators would return on their own closest() check anyway. One
|
||||
// check per record instead of one per text node and attribute matters
|
||||
// for xterm's DOM renderer, which replaces rows every frame (the split
|
||||
// pane, every tile of the grid).
|
||||
if (isSkippedRow(mutation.target) || shouldSkip(mutation.target)) continue;
|
||||
if (mutation.type === 'characterData') translateNode(mutation.target);
|
||||
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
||||
for (const added of mutation.addedNodes) translateNode(added);
|
||||
|
||||
@@ -50,13 +50,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||
// default to the primary pane. A second terminal (the split pane) passes its
|
||||
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||
_handleImagePaste(target = {}) {
|
||||
_handleImagePaste() {
|
||||
const self = this;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const sessionId = target.sessionId || this.activeSessionId;
|
||||
|
||||
// Create a hidden contenteditable div to receive the paste
|
||||
const trap = document.createElement('div');
|
||||
@@ -98,11 +93,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(function() {
|
||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||
// Refocus the terminal
|
||||
if (terminal) terminal.focus();
|
||||
if (self.terminal) self.terminal.focus();
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||
@@ -111,7 +106,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
if (text && terminal) terminal.paste(text);
|
||||
if (text && self.terminal) self.terminal.paste(text);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -131,10 +126,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** Upload a batch and normally insert its paths into the active terminal.
|
||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||
* into its textarea instead. `options.sessionId` names the session to upload
|
||||
* to (default: the active one). Returns successful paths in selection order. */
|
||||
* into its textarea instead. Returns successful paths in selection order. */
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
@@ -185,12 +179,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0 && options.insert !== false) {
|
||||
// Insert all paths in one shot, space-separated, in selection order, into
|
||||
// the session the batch was uploaded TO. Not sendInput(): it re-reads
|
||||
// activeSessionId, and after the awaits above that is whatever tab the
|
||||
// user switched to mid-upload, so the paths landed in the wrong session.
|
||||
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
|
||||
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
|
||||
+9
-205
@@ -189,10 +189,9 @@
|
||||
<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="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<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 class="icon-folder-closed" 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"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||
<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-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="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"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="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"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></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 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;">
|
||||
@@ -436,12 +435,6 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Tile grid (tile-grid.js): 1 to 6 sessions side by side, each a
|
||||
TerminalTile. A SIBLING of .terminal-wrap, never a parent: while
|
||||
.main.tiles-active is set the main terminal is parked (hidden) and
|
||||
this section takes its place. -->
|
||||
<section class="tile-grid" id="tileGrid" aria-label="Tiled sessions"></section>
|
||||
|
||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||
@@ -574,19 +567,6 @@
|
||||
<div class="file-browser-status" id="fileBrowserStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Git status panel (git-status-ui.js): what the active session's repo has not committed or pushed. -->
|
||||
<div class="git-status-panel" id="gitStatusPanel" role="dialog" aria-label="Git status">
|
||||
<div class="git-status-header">
|
||||
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
|
||||
<div class="git-status-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="git-status-body" id="gitStatusBody" data-i18n-skip></div>
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -791,13 +771,6 @@
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<!-- Git status of the active session's repository (git-status-ui.js). Optional and per-device
|
||||
(App Settings → Header & Panels → Bottom bar, default OFF), so it is hidden until that
|
||||
setting is on AND the session is a local git repository. -->
|
||||
<button type="button" class="btn-toolbar btn-sm btn-git-status" id="gitStatusBtn" hidden aria-expanded="false" aria-controls="gitStatusPanel" onclick="app.toggleGitStatusPanel()">
|
||||
<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"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>
|
||||
<span class="git-status-label" data-i18n-skip></span>
|
||||
</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -814,6 +787,7 @@
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
@@ -828,23 +802,11 @@
|
||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd data-i18n-skip>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Tiles</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Terminal</h4>
|
||||
<div class="shortcuts-grid">
|
||||
@@ -1860,22 +1822,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Key tester</span>
|
||||
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
|
||||
</div>
|
||||
<input type="text" id="keyTesterInput" class="set-input" data-raw-keys readonly autocomplete="off" spellcheck="false"
|
||||
placeholder="Click here, then press keys"
|
||||
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
|
||||
</div>
|
||||
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Header & Panels ═══════════════════════════════════════ -->
|
||||
@@ -1922,7 +1868,7 @@
|
||||
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
|
||||
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
|
||||
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw">
|
||||
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
||||
@@ -1932,8 +1878,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-search="tiles tile grid side by side several sessions" data-preview="header" data-preview-order="11.6"><input type="checkbox" id="appSettingsShowTileGridButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg><span>Tiles</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
@@ -1974,26 +1919,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Bottom bar</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="git status folders tree flat list collapsed expand files">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status: group files by folder <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Subagent windows</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2247,19 +2172,6 @@
|
||||
<option value="ultracode">Ultracode</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row set-row-block" data-search="advisor fable opus sonnet second opinion review consult">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Advisor</span>
|
||||
<span class="set-row-desc">A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor.</span>
|
||||
</div>
|
||||
<div class="set-segment" id="appSettingsAdvisorSegment" role="radiogroup" aria-label="Advisor"></div>
|
||||
<select id="appSettingsClaudeAdvisor" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
|
||||
<option value="">Default</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="fable">Fable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2562,30 +2474,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="mcpSyncGroup">
|
||||
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable MCP server sync</span>
|
||||
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Sync MCP servers across CLIs</span>
|
||||
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Notifications ════════════════════════════════════════════ -->
|
||||
@@ -2713,57 +2601,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="webhookGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Send alerts to a webhook</span>
|
||||
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Service</span></div>
|
||||
<select id="webhookKind" class="set-select">
|
||||
<option value="ntfy">ntfy</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="generic">Generic JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Webhook URL</span>
|
||||
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Which events</span>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="webhookScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
@@ -2888,20 +2725,6 @@
|
||||
</div>
|
||||
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>
|
||||
|
||||
<div class="set-group" id="doctorGroup">
|
||||
<div class="set-group-head"><h4>Diagnostics</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="diagnostics doctor dependencies tmux node claude codex check install">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Check this machine</span>
|
||||
<span class="set-row-desc">Runs <code>codeman doctor</code> on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing.</span>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-sm" id="doctorRunBtn" onclick="app.runDoctor()">Run checks</button>
|
||||
</div>
|
||||
<div id="doctorResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3042,30 +2865,18 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
|
||||
<h2>Create New</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb" id="newCaseBlurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
|
||||
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
|
||||
<span class="form-hint" id="newCaseNameHint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
|
||||
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row" id="newCaseCustomPathToggleRow">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseCustomPathToggle" onchange="app.toggleNewCaseCustomPath()"> 📁 Create in a custom folder</label>
|
||||
<span class="form-hint">By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.</span>
|
||||
</div>
|
||||
<div class="form-row" id="newCaseCustomPathRow" style="display:none">
|
||||
<label>Parent Folder</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="newCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openNewCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint" id="newCasePathPreview">Pick the folder the new case folder should be created inside.</span>
|
||||
</div>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleNewCaseCustomPath()"> 🐳 Run in an isolated Docker container</label>
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
|
||||
</div>
|
||||
@@ -3927,20 +3738,14 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
|
||||
<script defer src="mobile-ime-preview.js"></script>
|
||||
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
|
||||
<script defer src="tab-layout-browser.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="terminal-tile.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
<script defer src="tile-grid.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
@@ -3957,7 +3762,6 @@
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
|
||||
*
|
||||
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
|
||||
* Korean, dictation) is not visible inside the terminal until it commits, so
|
||||
* the user types blind. The controller listens to the helper textarea's
|
||||
* composition events and asks the caller to render the latest composition
|
||||
* (`phase: 'provisional'`), coalesced to one render per animation frame and
|
||||
* capped at 2048 characters. When xterm emits the committed text through
|
||||
* onData, the caller hands it to `consumeTerminalData()`, which switches the
|
||||
* preview to `phase: 'committed'` until something else shows the text: the
|
||||
* local echo overlay or a prediction (`completeCommit`), authoritative
|
||||
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
|
||||
* same 2 s bound applies while waiting for a commit that never reaches onData
|
||||
* (the user deleted the whole composition), so a later unrelated chunk is never
|
||||
* mistaken for it.
|
||||
*
|
||||
* Keydown ordering: xterm registers its textarea keydown listener in the
|
||||
* capture phase inside terminal.open() and finalizes the composition there
|
||||
* (CompositionHelper.keydown), emitting the commit through onData
|
||||
* synchronously. The controller therefore observes keydown in the capture
|
||||
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
|
||||
* before any listener on the textarea itself, and finalizes on exactly the
|
||||
* keys xterm does.
|
||||
*
|
||||
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
|
||||
* and every callback is wrapped so a failing render cannot block the wire.
|
||||
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
|
||||
* composition view untouched.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const COMMITTED_VISUAL_TTL = 2000;
|
||||
const PREVIEW_CAP = 2048;
|
||||
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
|
||||
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
|
||||
// keydown during a composition finalizes it.
|
||||
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
|
||||
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
||||
|
||||
function isIosWebKitTouch(nav = navigator) {
|
||||
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
|
||||
const platform = String(nav && nav.platform ? nav.platform : '');
|
||||
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
|
||||
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
|
||||
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
|
||||
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
// Must be the textarea or an ancestor of it, so its capture listener runs
|
||||
// before xterm's capture listener on the textarea.
|
||||
const keydownTarget = options.keydownTarget || textarea;
|
||||
const render = typeof options.render === 'function' ? options.render : function () {};
|
||||
const clear = typeof options.clear === 'function' ? options.clear : function () {};
|
||||
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
|
||||
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
|
||||
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
|
||||
let generation = 0;
|
||||
let composing = false;
|
||||
let awaitingCommit = false;
|
||||
let committed = false;
|
||||
let latestValue = '';
|
||||
let renderPhase = null;
|
||||
let frameToken = null;
|
||||
let timerToken = null;
|
||||
let finalizedByKeydown = false;
|
||||
let destroyed = false;
|
||||
let invokingClear = false;
|
||||
|
||||
function safely(callback, ...args) {
|
||||
try {
|
||||
return callback(...args);
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScheduledFrame() {
|
||||
const token = frameToken;
|
||||
frameToken = null;
|
||||
if (token && token.id !== undefined) safely(cancelFrame, token.id);
|
||||
}
|
||||
|
||||
function cancelCommittedTimer() {
|
||||
const token = timerToken;
|
||||
timerToken = null;
|
||||
if (token && token.id !== undefined) safely(clearTimer, token.id);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
if (invokingClear) return;
|
||||
invokingClear = true;
|
||||
safely(clear);
|
||||
invokingClear = false;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
generation += 1;
|
||||
cancelScheduledFrame();
|
||||
cancelCommittedTimer();
|
||||
composing = false;
|
||||
awaitingCommit = false;
|
||||
committed = false;
|
||||
latestValue = '';
|
||||
renderPhase = null;
|
||||
finalizedByKeydown = false;
|
||||
clearVisual();
|
||||
}
|
||||
|
||||
function scheduleLatestPreview(phase) {
|
||||
if (destroyed) return;
|
||||
renderPhase = phase;
|
||||
if (frameToken) return;
|
||||
const token = { generation, id: undefined };
|
||||
frameToken = token;
|
||||
const callback = function () {
|
||||
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
|
||||
frameToken = null;
|
||||
const value = latestValue.slice(0, PREVIEW_CAP);
|
||||
const phaseToRender = renderPhase;
|
||||
safely(render, { text: value, phase: phaseToRender });
|
||||
};
|
||||
const id = safely(scheduleFrame, callback);
|
||||
if (frameToken === token) {
|
||||
if (id === undefined) frameToken = null;
|
||||
else token.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
function beginComposition() {
|
||||
cleanup();
|
||||
if (destroyed) return;
|
||||
composing = true;
|
||||
}
|
||||
|
||||
function updateComposition(event) {
|
||||
if (!composing) return;
|
||||
latestValue = event.data == null ? '' : String(event.data);
|
||||
scheduleLatestPreview('provisional');
|
||||
}
|
||||
|
||||
function onComposingInput(event) {
|
||||
if (!event.isComposing) return;
|
||||
updateComposition({ data: event.data == null ? textarea.value : event.data });
|
||||
}
|
||||
|
||||
function armFallbackTimer(isCurrent) {
|
||||
const token = { generation, id: undefined };
|
||||
timerToken = token;
|
||||
const callback = function () {
|
||||
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
|
||||
timerToken = null;
|
||||
cleanup();
|
||||
};
|
||||
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
|
||||
if (timerToken === token) {
|
||||
if (id === undefined) {
|
||||
timerToken = null;
|
||||
return false;
|
||||
}
|
||||
token.id = id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function finalizeComposition(value, fromKeydown) {
|
||||
if (!composing) return;
|
||||
composing = false;
|
||||
awaitingCommit = true;
|
||||
committed = false;
|
||||
finalizedByKeydown = fromKeydown;
|
||||
latestValue = value == null ? latestValue : String(value);
|
||||
scheduleLatestPreview('provisional');
|
||||
// A commit that never reaches onData (the composition was deleted, so
|
||||
// xterm emits nothing) must not leave the controller waiting forever.
|
||||
cancelCommittedTimer();
|
||||
const owner = generation;
|
||||
armFallbackTimer(function () {
|
||||
return awaitingCommit && generation === owner;
|
||||
});
|
||||
}
|
||||
|
||||
function onCompositionEnd(event) {
|
||||
if (finalizedByKeydown) {
|
||||
finalizedByKeydown = false;
|
||||
return;
|
||||
}
|
||||
finalizeComposition(event.data, false);
|
||||
}
|
||||
|
||||
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
|
||||
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
|
||||
// 20/229 and 16/17/18 keep the composition open and every other keyCode
|
||||
// finalizes it. `isComposing` and `key` are deliberately not consulted,
|
||||
// because xterm does not consult them.
|
||||
function onKeydown(event) {
|
||||
if (keydownTarget !== textarea && event.target !== textarea) return;
|
||||
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
|
||||
finalizeComposition(latestValue, true);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (destroyed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function consumeTerminalData(data) {
|
||||
if (
|
||||
destroyed ||
|
||||
!awaitingCommit ||
|
||||
typeof data !== 'string' ||
|
||||
data.length === 0 ||
|
||||
CONTROL_OR_LINE_BREAK.test(data)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
generation += 1;
|
||||
const owner = generation;
|
||||
cancelScheduledFrame();
|
||||
cancelCommittedTimer();
|
||||
composing = false;
|
||||
awaitingCommit = false;
|
||||
committed = true;
|
||||
latestValue = data;
|
||||
renderPhase = 'committed';
|
||||
safely(onCommit, data);
|
||||
if (destroyed || generation !== owner || !committed) return true;
|
||||
|
||||
scheduleLatestPreview('committed');
|
||||
if (destroyed || generation !== owner || !committed) return true;
|
||||
|
||||
const armed = armFallbackTimer(function () {
|
||||
return committed;
|
||||
});
|
||||
if (!armed && !destroyed && generation === owner && committed) cleanup();
|
||||
return true;
|
||||
}
|
||||
|
||||
function completeCommit(result) {
|
||||
if (destroyed || !result || result.predicted !== true || !committed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function noteAuthoritativeOutput() {
|
||||
if (destroyed || !committed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
[textarea, 'compositionstart', beginComposition],
|
||||
[textarea, 'compositionupdate', updateComposition],
|
||||
[textarea, 'input', onComposingInput],
|
||||
[textarea, 'compositionend', onCompositionEnd],
|
||||
[keydownTarget, 'keydown', onKeydown, true],
|
||||
[textarea, 'blur', reset],
|
||||
];
|
||||
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
return {
|
||||
consumeTerminalData,
|
||||
completeCommit,
|
||||
noteAuthoritativeOutput,
|
||||
reset,
|
||||
destroy,
|
||||
get state() {
|
||||
return {
|
||||
generation,
|
||||
composing,
|
||||
awaitingCommit,
|
||||
committed,
|
||||
latest: latestValue,
|
||||
framePending: frameToken !== null,
|
||||
timerPending: timerToken !== null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.MobileImePreview = { create, isIosWebKitTouch };
|
||||
})(globalThis);
|
||||
@@ -1038,7 +1038,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
|
||||
const effort = this.getEffortSetting(ralphGlobalSettings);
|
||||
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
|
||||
const res = await fetch('/api/ralph-loop/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1051,7 +1050,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
planItems: enabledItems?.length ? enabledItems : undefined,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(advisorModel ? { advisorModel } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -153,6 +153,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const edges = this._collectLineageEdges();
|
||||
if (edges.length === 0) return;
|
||||
this._lineageEdgeCount = edges.length;
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1 — reads.
|
||||
@@ -161,35 +162,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stripRect = strip.getBoundingClientRect();
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||
// to anchor to, so its end of the arc moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||
// header would be an arc from a row to itself: skipped.
|
||||
const resolveEndpoint = (id) => {
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
|
||||
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
|
||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||
};
|
||||
const resolvedEdges = [];
|
||||
for (const edge of edges) {
|
||||
const parentEndpoint = resolveEndpoint(edge.parentId);
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (parentEndpoint.key === childEndpoint.key) continue;
|
||||
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
|
||||
for (const endpoint of [parentEndpoint, childEndpoint]) {
|
||||
if (rects.has(endpoint.key)) continue;
|
||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||
for (const id of [edge.parentId, edge.childId]) {
|
||||
const key = 'tab:' + id;
|
||||
if (rects.has(key)) continue;
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
rects.set(key, tab ? tab.getBoundingClientRect() : null);
|
||||
}
|
||||
}
|
||||
this._lineageEdgeCount = resolvedEdges.length;
|
||||
|
||||
// PHASE 2 — writes, from the cache only.
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
for (const edge of edges) {
|
||||
const parentRect = rects.get('tab:' + edge.parentId);
|
||||
const childRect = rects.get('tab:' + edge.childId);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
@@ -206,8 +191,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// The working class marches the dashes, so an active worker is visible along
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||
@@ -227,7 +211,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
|
||||
+23
-194
@@ -169,17 +169,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return valid.includes(effort) ? effort : undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the advisor model for new Claude sessions from global settings.
|
||||
* Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own
|
||||
* /advisor choice). Sent as the `advisorModel` payload field; the backend merges it
|
||||
* into the launch's `claude --settings` JSON, so /advisor still switches it in-session.
|
||||
*/
|
||||
getAdvisorSetting(globalSettings) {
|
||||
const advisor = globalSettings?.claudeAdvisorModel;
|
||||
return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Quick Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -573,10 +562,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
if (session?.id) this._onSessionCreated(session);
|
||||
// A session this tab's Run created joins an open tile grid (tile-grid.js),
|
||||
// so Run's selectSession() below focuses its tile instead of leaving the
|
||||
// grid. Only here: sessions created elsewhere arrive by session:created.
|
||||
this._joinTileGridFromRun?.(sessionId);
|
||||
// session:created normally uses the debounced renderer. The direct POST path
|
||||
// needs the tab in the DOM before selectSession() marks it active.
|
||||
this._renderSessionTabsImmediate?.();
|
||||
@@ -1878,14 +1863,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get case path first
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
let caseData = caseLookup?.data ?? {};
|
||||
let caseData = (await caseRes.json())?.data ?? {};
|
||||
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
// Create the case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1984,7 +1965,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
const effort = this.getEffortSetting(globalSettings);
|
||||
const advisorModel = this.getAdvisorSetting(globalSettings);
|
||||
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
|
||||
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
|
||||
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||
@@ -2000,7 +1980,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
workingDir, name,
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(advisorModel ? { advisorModel } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
@@ -2092,14 +2071,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
let caseData = caseLookup?.data ?? {};
|
||||
let caseData = (await caseRes.json())?.data ?? {};
|
||||
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
// Create the case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2601,62 +2576,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return typeof confirmed === 'string' ? confirmed : name;
|
||||
},
|
||||
|
||||
/**
|
||||
* Write an inline rename, one PUT per session at a time, in the order the
|
||||
* user made them. The editor can be reopened (or cancelled, or replaced by a
|
||||
* group rename) while a PUT is in flight, so the write lives here rather than
|
||||
* in the editor: a confirmed name is applied locally even after its editor is
|
||||
* gone, and the "already that name" check runs only once the earlier writes
|
||||
* have landed, so confirming the name still on screen is a real write.
|
||||
* Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects,
|
||||
* and reports a failed write itself, since its editor may be gone by then.
|
||||
* `_inlineRenamePending` holds the newest queued name per session, so an
|
||||
* editor reopened over a write in flight starts from that name rather than
|
||||
* the one the server has not replaced yet.
|
||||
*/
|
||||
_queueInlineSessionName(sessionId, desiredName) {
|
||||
this._inlineRenameWrites ??= new Map();
|
||||
this._inlineRenamePending ??= new Map();
|
||||
const writes = this._inlineRenameWrites;
|
||||
const pending = this._inlineRenamePending;
|
||||
pending.set(sessionId, desiredName);
|
||||
// Chained from a settled promise, so one rejected write cannot stop the
|
||||
// writes queued behind it.
|
||||
const prev = (writes.get(sessionId) || Promise.resolve()).catch(() => {});
|
||||
const task = prev.then(async () => {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return { status: 'deleted' };
|
||||
if (session.name === desiredName) return { status: 'confirmed' };
|
||||
let confirmed = null;
|
||||
try {
|
||||
confirmed = await this._putSessionName(sessionId, desiredName);
|
||||
} catch {
|
||||
// A failure is a value, so a later write in the chain still runs.
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) return { status: 'deleted' };
|
||||
if (confirmed === null) {
|
||||
this.showToast('Failed to rename', 'error');
|
||||
return { status: 'failed' };
|
||||
}
|
||||
try {
|
||||
this._applyLocalSessionName(sessionId, confirmed);
|
||||
this.renderSessionTabs();
|
||||
} catch (err) {
|
||||
// The server holds the name; a local repaint failing is not a failed write.
|
||||
console.error('[rename] applying the confirmed name failed', err);
|
||||
}
|
||||
return { status: 'confirmed' };
|
||||
});
|
||||
writes.set(sessionId, task);
|
||||
const cleanup = () => {
|
||||
if (writes.get(sessionId) !== task) return;
|
||||
writes.delete(sessionId);
|
||||
pending.delete(sessionId);
|
||||
};
|
||||
task.then(cleanup, cleanup);
|
||||
return task;
|
||||
},
|
||||
|
||||
async saveSessionName() {
|
||||
if (!this.editingSessionId) return;
|
||||
// Captured: the modal can be closed (or switched to another session) while
|
||||
@@ -2945,11 +2864,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabName.classList.add('tab-name-renaming');
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
// A rename still in flight is the user's last word, not the name the
|
||||
// server has yet to replace: start from it, and compare against it below.
|
||||
const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name;
|
||||
const renameInFlight = shownName !== session.name;
|
||||
const parsed = parseSessionPrefix(shownName);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
const originalContent = tabName.textContent;
|
||||
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
|
||||
const restoreOriginalChildren = () => {
|
||||
@@ -2970,17 +2885,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = parsed ? parsed.suffix : (shownName || '');
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
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. The header editor may shrink to
|
||||
// nothing, while a rail or sidebar row always keeps room to type.
|
||||
const inRail = !!tabName.closest('.tab-rail');
|
||||
const inSidebar = !inRail && !!this.isSessionSidebarActive?.();
|
||||
const renameWidth = inRail ? 'auto' : inSidebar ? '100%' : '80px';
|
||||
const renameMinWidth = inRail || inSidebar ? '4rem' : '0';
|
||||
input.style.cssText = `width: ${renameWidth}; min-width: ${renameMinWidth}; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
||||
// should give the whole line to the input.
|
||||
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();
|
||||
@@ -3030,20 +2941,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const suffix = input.value.trim();
|
||||
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
||||
// An unchanged confirm puts the old label back, unless the editor opened
|
||||
// over a rename in flight: that label was repainted from the server's
|
||||
// older name, so show the in-flight name rather than make it look lost.
|
||||
if (fullName === shownName && !renameInFlight) restoreOriginalChildren();
|
||||
if (fullName === session.name) restoreOriginalChildren();
|
||||
else tabName.textContent = fullName || originalContent;
|
||||
|
||||
// Skip the API call if the session vanished between focus and blur. The
|
||||
// queue applies the confirmed name to this.sessions before the re-render
|
||||
// below repaints from it (see _applyLocalSessionName()).
|
||||
if (this.sessions.has(sessionId)) {
|
||||
const result = await this._queueInlineSessionName(sessionId, fullName);
|
||||
// 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;
|
||||
// The queue reports a failure itself; the editor only puts its label back.
|
||||
if (result.status === 'failed') restoreOriginalChildren();
|
||||
if (confirmed === null) {
|
||||
restoreOriginalChildren();
|
||||
this.showToast('Failed to rename', 'error');
|
||||
} else {
|
||||
// The re-render below repaints from this.sessions, so the new name has
|
||||
// to be in the map before it runs (see _applyLocalSessionName()).
|
||||
this._applyLocalSessionName(sessionId, confirmed);
|
||||
}
|
||||
}
|
||||
// Re-render tabs to restore full tab structure
|
||||
completeCurrentRename();
|
||||
@@ -3172,16 +3085,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
showCreateCaseModal() {
|
||||
document.getElementById('newCaseName').value = '';
|
||||
document.getElementById('newCaseDescription').value = '';
|
||||
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
|
||||
// server refuses it (it writes outside the cases directory and into the shared registry).
|
||||
const customToggle = document.getElementById('newCaseCustomPathToggle');
|
||||
if (customToggle) customToggle.checked = false;
|
||||
const customPath = document.getElementById('newCasePath');
|
||||
if (customPath) customPath.value = '';
|
||||
const me = window.__codemanUser || {};
|
||||
const customRow = document.getElementById('newCaseCustomPathToggleRow');
|
||||
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
this.toggleNewCaseCustomPath();
|
||||
document.getElementById('linkCaseName').value = '';
|
||||
document.getElementById('linkCasePath').value = '';
|
||||
const remoteFields = [
|
||||
@@ -3349,71 +3252,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom-folder row for Create New: shows or hides the parent-folder field, and keeps it and the
|
||||
* Docker option mutually exclusive (a Docker case has its own workspace flow, and the quick-create
|
||||
* route has no `path`).
|
||||
*/
|
||||
toggleNewCaseCustomPath() {
|
||||
const custom = document.getElementById('newCaseCustomPathToggle');
|
||||
const docker = document.getElementById('newCaseDocker');
|
||||
const row = document.getElementById('newCaseCustomPathRow');
|
||||
if (!custom || !row) return;
|
||||
row.style.display = custom.checked ? '' : 'none';
|
||||
// The "under ~/codeman-cases" wording is wrong while a custom folder is picked.
|
||||
const blurb = document.getElementById('newCaseBlurb');
|
||||
if (blurb) {
|
||||
blurb.textContent = custom.checked
|
||||
? 'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.'
|
||||
: 'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.';
|
||||
}
|
||||
const nameHint = document.getElementById('newCaseNameHint');
|
||||
if (nameHint) {
|
||||
nameHint.textContent = custom.checked
|
||||
? 'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.'
|
||||
: 'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/';
|
||||
}
|
||||
custom.disabled = !!docker?.checked;
|
||||
custom.title = docker?.checked ? 'Not available for a Docker case' : '';
|
||||
if (docker) {
|
||||
docker.disabled = custom.checked;
|
||||
docker.title = custom.checked ? 'Not available with a custom folder' : '';
|
||||
}
|
||||
this.updateNewCasePathPreview();
|
||||
},
|
||||
|
||||
/** The folder the case would be created in: the parent field plus the case name. */
|
||||
_newCaseTargetPath() {
|
||||
const rawParent = (document.getElementById('newCasePath')?.value || '').trim();
|
||||
const name = (document.getElementById('newCaseName')?.value || '').trim();
|
||||
if (!rawParent || !name) return '';
|
||||
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
|
||||
const parent = rawParent.replace(/\/+$/, '');
|
||||
return `${parent}/${name}`;
|
||||
},
|
||||
|
||||
updateNewCasePathPreview() {
|
||||
const hint = document.getElementById('newCasePathPreview');
|
||||
if (!hint) return;
|
||||
const target = this._newCaseTargetPath();
|
||||
hint.textContent = target ? `Will create: ${target}` : 'Pick the folder the new case folder should be created inside.';
|
||||
},
|
||||
|
||||
openNewCasePathPicker() {
|
||||
const input = document.getElementById('newCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Choose the folder to create the case in',
|
||||
initialPath: input.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
input.value = path;
|
||||
this.updateNewCasePathPreview();
|
||||
input.focus();
|
||||
input.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async createCase() {
|
||||
const name = document.getElementById('newCaseName').value.trim();
|
||||
const description = document.getElementById('newCaseDescription').value.trim();
|
||||
@@ -3431,16 +3269,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// One-click "Run in Docker": create the case folder AND a container, then start
|
||||
// a session inside it. Optional expandable settings override the defaults.
|
||||
const inDocker = document.getElementById('newCaseDocker')?.checked;
|
||||
const customFolder = !inDocker && document.getElementById('newCaseCustomPathToggle')?.checked;
|
||||
if (customFolder && !(document.getElementById('newCasePath')?.value || '').trim()) {
|
||||
this.showToast('Choose the folder to create the case in', 'error');
|
||||
return;
|
||||
}
|
||||
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
|
||||
const payload = inDocker
|
||||
? { name, description, ...this._collectDockerQuickSettings() }
|
||||
: customFolder
|
||||
? { name, description, path: this._newCaseTargetPath() }
|
||||
: { name, description };
|
||||
|
||||
try {
|
||||
@@ -3463,9 +3294,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Start a session INSIDE the container (routes through quick-start).
|
||||
await this.runClaude();
|
||||
} else {
|
||||
// The server's path is the folder actually created (~ expanded, symlinks resolved).
|
||||
const createdIn = data.data?.case?.path || payload.path;
|
||||
this.showToast(customFolder ? `Case "${name}" created in ${createdIn}` : `Case "${name}" created`, 'success');
|
||||
this.showToast(`Case "${name}" created`, 'success');
|
||||
}
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to create case', 'error');
|
||||
|
||||
@@ -416,21 +416,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
|
||||
// The routes read the SAVED setting, so remember what it was on open: switching it on
|
||||
// here does nothing server-side until Save (see mcpSync()).
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||
@@ -452,8 +443,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
||||
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -538,7 +527,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
|
||||
document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? '';
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
||||
@@ -639,7 +627,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncSettingsChips();
|
||||
this._syncModelCards();
|
||||
this._syncEffortSegment();
|
||||
this._syncAdvisorSegment();
|
||||
// Back to the top of the document (one scroll, not a tab reset). Updates is
|
||||
// first now: the version this install is running, and whether a newer one is
|
||||
// waiting, are the two things worth seeing before any preference. The rest of
|
||||
@@ -731,7 +718,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
|
||||
this._buildModelCards();
|
||||
this._buildEffortSegment();
|
||||
this._buildAdvisorSegment();
|
||||
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
|
||||
// after the first open, and the menu must not drift from the rail.
|
||||
this._buildSettingsJumpMenu();
|
||||
@@ -1007,28 +993,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_buildEffortSegment() {
|
||||
this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
|
||||
},
|
||||
|
||||
_syncEffortSegment() {
|
||||
this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
|
||||
},
|
||||
|
||||
_buildAdvisorSegment() {
|
||||
this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
|
||||
},
|
||||
|
||||
_syncAdvisorSegment() {
|
||||
this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a radio segment as a view over a hidden <select>, which stays the single
|
||||
* source of truth for load/save (the same contract as the model cards).
|
||||
*/
|
||||
_buildSelectSegment(selectId, segId) {
|
||||
const select = document.getElementById(selectId);
|
||||
const seg = document.getElementById(segId);
|
||||
const select = document.getElementById('appSettingsThinkingEffort');
|
||||
const seg = document.getElementById('appSettingsEffortSegment');
|
||||
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
|
||||
seg.innerHTML = '';
|
||||
[...select.options].forEach(opt => {
|
||||
@@ -1039,16 +1005,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.textContent = opt.textContent;
|
||||
btn.addEventListener('click', () => {
|
||||
select.value = opt.value;
|
||||
this._syncSelectSegment(selectId, segId);
|
||||
this._syncEffortSegment();
|
||||
});
|
||||
seg.appendChild(btn);
|
||||
});
|
||||
seg.dataset.built = '1';
|
||||
},
|
||||
|
||||
_syncSelectSegment(selectId, segId) {
|
||||
const select = document.getElementById(selectId);
|
||||
const seg = document.getElementById(segId);
|
||||
_syncEffortSegment() {
|
||||
const select = document.getElementById('appSettingsThinkingEffort');
|
||||
const seg = document.getElementById('appSettingsEffortSegment');
|
||||
if (!select || !seg) return;
|
||||
seg.querySelectorAll('button').forEach(btn => {
|
||||
const on = btn.dataset.value === (select.value || '');
|
||||
@@ -1148,301 +1114,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updateCheck = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
|
||||
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
|
||||
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
|
||||
*/
|
||||
keyTesterEvent(ev) {
|
||||
const log = document.getElementById('keyTesterLog');
|
||||
if (!log) return;
|
||||
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
|
||||
// The field is readonly, so nothing is typed into it either way.
|
||||
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
|
||||
const line =
|
||||
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
|
||||
`mods=${mods.join('+') || 'none'}` +
|
||||
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
|
||||
(ev.repeat ? ' (repeat)' : '');
|
||||
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
|
||||
log.textContent = lines.slice(-14).join('\n');
|
||||
log.style.display = 'block';
|
||||
},
|
||||
|
||||
/**
|
||||
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
|
||||
* shown disabled, because both endpoints would only answer 403. Called on open and from the
|
||||
* checkbox's own onchange (assigning .checked fires no change event).
|
||||
*/
|
||||
applyMcpSyncVisibility() {
|
||||
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
|
||||
const row = document.getElementById('mcpSyncActionRow');
|
||||
if (row) row.style.display = on ? '' : 'none';
|
||||
const out = this.$('mcpSyncResult');
|
||||
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
|
||||
this._applyMcpSyncAdminGate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
|
||||
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
|
||||
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
|
||||
* `window.__codemanUser`'s real role can resolve after settings were opened once.
|
||||
*/
|
||||
_applyMcpSyncAdminGate() {
|
||||
const group = document.getElementById('mcpSyncGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a
|
||||
* non-admin gets no Diagnostics group instead of a button that can only answer 403. Also
|
||||
* wired to `codeman:me` for the same late-resolving role as the groups above.
|
||||
*/
|
||||
_applyDoctorAdminGate() {
|
||||
const group = document.getElementById('doctorGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html) => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; }
|
||||
};
|
||||
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
|
||||
if (!this._mcpSyncSavedOn) {
|
||||
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
|
||||
return;
|
||||
}
|
||||
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
|
||||
show('Working…');
|
||||
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
show(escapeHtml(body?.error || 'MCP sync failed.'));
|
||||
return;
|
||||
}
|
||||
const data = body.data;
|
||||
const rows = data.targets.map((t) => {
|
||||
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
|
||||
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
|
||||
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
|
||||
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
|
||||
const verb = data.applied ? 'added' : 'would add';
|
||||
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
|
||||
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
|
||||
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
|
||||
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
|
||||
});
|
||||
const conflicts = data.conflicts.length
|
||||
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
const disabled = data.disabled?.length
|
||||
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
const unsupported = data.unsupported?.length
|
||||
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
|
||||
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
|
||||
* this page. The URL box is write-only; the status line shows scheme + host only.
|
||||
*
|
||||
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
|
||||
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
|
||||
* saveWebhook() beside the settings PUT the same way it saves the model config
|
||||
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
|
||||
* `_webhookPending()` can tell an edited group from an untouched one.
|
||||
*/
|
||||
_webhookSay(text, bad = false) {
|
||||
const out = document.getElementById('webhookResult');
|
||||
if (!out) return;
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
|
||||
},
|
||||
|
||||
async loadWebhook() {
|
||||
const group = document.getElementById('webhookGroup');
|
||||
if (!group) return;
|
||||
const res = await this._api('/api/webhook');
|
||||
if (!res || !res.ok) {
|
||||
this._webhookLoaded = null;
|
||||
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
|
||||
return;
|
||||
}
|
||||
let body = null;
|
||||
try { body = await res.json(); } catch { /* leave hidden */ }
|
||||
if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; }
|
||||
const d = body.data;
|
||||
group.style.display = '';
|
||||
document.getElementById('webhookEnabled').checked = d.enabled === true;
|
||||
document.getElementById('webhookKind').value = d.kind;
|
||||
document.getElementById('webhookScope').value = d.scope;
|
||||
const url = document.getElementById('webhookUrl');
|
||||
url.value = '';
|
||||
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
|
||||
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
|
||||
const clearBtn = document.getElementById('webhookClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none';
|
||||
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
|
||||
this._webhookLoaded = {
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
kind: document.getElementById('webhookKind').value,
|
||||
scope: document.getElementById('webhookScope').value,
|
||||
};
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
this._webhookSay(
|
||||
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
|
||||
!d.lastResult.ok
|
||||
);
|
||||
} else {
|
||||
this._webhookSay('');
|
||||
}
|
||||
},
|
||||
|
||||
/** True when the visible webhook group differs from what loadWebhook() last showed. */
|
||||
_webhookPending() {
|
||||
const group = document.getElementById('webhookGroup');
|
||||
const loaded = this._webhookLoaded;
|
||||
if (!group || group.style.display === 'none' || !loaded) return false;
|
||||
return (
|
||||
document.getElementById('webhookUrl').value.trim() !== '' ||
|
||||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
|
||||
document.getElementById('webhookKind').value !== loaded.kind ||
|
||||
document.getElementById('webhookScope').value !== loaded.scope
|
||||
);
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveWebhook() {
|
||||
const payload = {
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
kind: document.getElementById('webhookKind').value,
|
||||
scope: document.getElementById('webhookScope').value,
|
||||
};
|
||||
const url = document.getElementById('webhookUrl').value.trim();
|
||||
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
|
||||
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
const error = body?.error || 'Could not save the webhook.';
|
||||
this._webhookSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadWebhook();
|
||||
this._webhookSay('Saved.');
|
||||
return '';
|
||||
},
|
||||
|
||||
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
|
||||
async clearWebhook() {
|
||||
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
|
||||
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._webhookSay(body?.error || 'Could not remove the webhook URL.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadWebhook();
|
||||
this._webhookSay('Webhook URL removed.');
|
||||
},
|
||||
|
||||
async testWebhook() {
|
||||
const btn = document.getElementById('webhookTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
|
||||
this._webhookSay('Sending…');
|
||||
const res = await this._apiPost('/api/webhook/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._webhookSay(body?.error || 'Could not send the test.', true);
|
||||
return;
|
||||
}
|
||||
const r = body.data;
|
||||
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
|
||||
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
|
||||
*/
|
||||
async runDoctor() {
|
||||
const out = document.getElementById('doctorResult');
|
||||
const btn = document.getElementById('doctorRunBtn');
|
||||
if (!out) return;
|
||||
const say = (text) => {
|
||||
out.replaceChildren(document.createTextNode(text));
|
||||
out.style.display = 'block';
|
||||
};
|
||||
if (btn) btn.disabled = true;
|
||||
say('Checking…');
|
||||
try {
|
||||
const res = await this._api('/api/doctor');
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
say(body?.error || 'The check failed.');
|
||||
return;
|
||||
}
|
||||
const { tools, summary, platform } = body.data;
|
||||
const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' };
|
||||
const list = document.createElement('ul');
|
||||
list.style.margin = '0';
|
||||
list.style.paddingLeft = '1.2em';
|
||||
for (const t of tools) {
|
||||
const li = document.createElement('li');
|
||||
const strong = document.createElement('b');
|
||||
// As the terminal doctor marks it: a missing OPTIONAL tool is ○, only a required one ✗.
|
||||
const mark = t.status === 'missing' && !t.required ? '○' : glyph[t.status] || '?';
|
||||
strong.textContent = `${mark} ${t.label}`;
|
||||
li.append(strong);
|
||||
const bits = [t.status];
|
||||
if (t.version) bits.push(t.version);
|
||||
if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional');
|
||||
if (t.reason) bits.push(t.reason);
|
||||
li.append(document.createTextNode(` ${bits.join(' · ')}`));
|
||||
if (t.path) {
|
||||
const p = document.createElement('div');
|
||||
p.className = 'mono';
|
||||
p.textContent = t.path;
|
||||
li.append(p);
|
||||
}
|
||||
if (t.status === 'missing' && t.installHint) {
|
||||
const h = document.createElement('div');
|
||||
h.textContent = `Install: ${t.installHint}`;
|
||||
li.append(h);
|
||||
}
|
||||
list.append(li);
|
||||
}
|
||||
const head = document.createElement('p');
|
||||
head.textContent =
|
||||
`${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` +
|
||||
` (${platform.environment})`;
|
||||
out.replaceChildren(head, list);
|
||||
out.style.display = 'block';
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
_setUpdateResult(html) {
|
||||
const el = this.$('updateResult');
|
||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||
@@ -2488,12 +2159,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||
showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
@@ -2502,8 +2171,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -2547,7 +2214,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
|
||||
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
|
||||
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
||||
@@ -2732,9 +2398,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||
// (moving it into displayKeys alone is not the strip; this is).
|
||||
showSplitButton: _ssp,
|
||||
// Same as Split: a per-device header button (and the Tiles chord), absent
|
||||
// from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT.
|
||||
showTileGridButton: _stg,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
@@ -2759,9 +2422,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||
showGitStatus: _sgs,
|
||||
gitStatusTree: _gst,
|
||||
showTabDetachButton: _tdb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
@@ -2771,7 +2431,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
sessionLineageLines: _sll,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -2796,16 +2455,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
|
||||
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
|
||||
// settings.json), so like the model config above it is saved beside the settings PUT, not in
|
||||
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
|
||||
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
|
||||
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
|
||||
if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else {
|
||||
this.showToast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
// Show tunnel-specific feedback if toggled on
|
||||
if (settings.tunnelEnabled) {
|
||||
@@ -2816,11 +2466,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Settings saved locally', 'warning');
|
||||
}
|
||||
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
// Voice availability is a server-side answer, so re-probe after a save:
|
||||
// otherwise the mic keeps using the pre-save provider until the next reload.
|
||||
@@ -3425,7 +3071,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showSplitButton: false,
|
||||
showTileGridButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
@@ -3638,10 +3283,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
this._applySplitButtonVisibility?.(showSplitButton);
|
||||
|
||||
// Tiles button: same gate and backstop as Split (tile-grid.js).
|
||||
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
|
||||
this._applyTileGridButtonVisibility?.(showTileGridButton);
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
@@ -3706,10 +3347,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
}
|
||||
|
||||
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||
// match the setting, so a live toggle needs no reload.
|
||||
this.applyGitStatusVisibility?.();
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -4058,12 +3695,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
'showTileGridButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
@@ -4470,6 +4106,4 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
window.app?._applyMcpSyncAdminGate?.();
|
||||
window.app?._applyDoctorAdminGate?.();
|
||||
});
|
||||
|
||||
+34
-1446
File diff suppressed because it is too large
Load Diff
@@ -1,750 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Browser projection and editing of the owner tab layout.
|
||||
*
|
||||
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
|
||||
* is the server model). Browser assets cannot import that TypeScript, so this
|
||||
* module is a small, dependency-free mirror that owns four things:
|
||||
*
|
||||
* 1. Projection: which live sessions and open web tabs land in which group,
|
||||
* and which rows a collapsed group hides.
|
||||
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
|
||||
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
|
||||
* byte-identical to the flat rail's row.
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* read degrades to the flat rail with a capped, backed-off retry.
|
||||
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
|
||||
* row) applied optimistically and saved through ONE serialized
|
||||
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
|
||||
* version conflict.
|
||||
*
|
||||
* The server stays the only authority for layout content. Collapse is a
|
||||
* per-device view preference and lives in localStorage only.
|
||||
*
|
||||
* Grouped rendering is opt-in by construction: a layout with no groups (every
|
||||
* owner until they create one) projects to `null`, and the caller keeps the flat
|
||||
* rail exactly as it was.
|
||||
*
|
||||
* @dependency none
|
||||
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
|
||||
*/
|
||||
|
||||
(function initCodemanTabLayout(global) {
|
||||
'use strict';
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
|
||||
|
||||
const refKey = (ref) => `${ref.kind}:${ref.id}`;
|
||||
const validRef = (ref) =>
|
||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
// `placement: 'manual'` must survive the round trip: the browser writes whole
|
||||
// layouts back, and dropping it would re-attach a hand-placed child session to
|
||||
// its parent's subtree on the next save.
|
||||
const copyRef = (r) =>
|
||||
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
|
||||
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
|
||||
|
||||
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
|
||||
const MAX_GROUPS = 32;
|
||||
const MAX_NAME_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
|
||||
* server adding model fields cannot leak half-understood state into the view.
|
||||
*/
|
||||
function normalizeLayout(value) {
|
||||
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
|
||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||
return {
|
||||
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
|
||||
groups: groups
|
||||
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
name: typeof group.name === 'string' ? group.name : '',
|
||||
refs: copyRefs(group.refs),
|
||||
})),
|
||||
ungrouped: copyRefs(value.ungrouped),
|
||||
};
|
||||
}
|
||||
|
||||
function hasGroups(layout) {
|
||||
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
|
||||
}
|
||||
|
||||
/** Stored collapse ids, or null when the stored value is not a JSON array. */
|
||||
function parseCollapsedIds(raw) {
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? stableIds(parsed) : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
|
||||
* or write threw), and the caller then keeps every group expanded. A malformed
|
||||
* VALUE is not a store failure: it reads as "nothing collapsed" and is
|
||||
* rewritten, or a shape left behind by another build (a rollback) would leave
|
||||
* collapse disabled on this device for good.
|
||||
*/
|
||||
function loadCollapsedGroupIds(storage, validGroupIds) {
|
||||
try {
|
||||
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
|
||||
const loaded = parsed || [];
|
||||
if (validGroupIds === undefined) return { ids: loaded, ok: true };
|
||||
// Garbage-collect ids of groups that no longer exist, so a deleted group's
|
||||
// id cannot silently collapse a future group that reuses it.
|
||||
const valid = new Set(stableIds(validGroupIds));
|
||||
const kept = loaded.filter((id) => valid.has(id));
|
||||
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
|
||||
return { ids: kept, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsedGroupIds(storage, groupIds) {
|
||||
const ids = stableIds(groupIds);
|
||||
try {
|
||||
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
|
||||
return { ids, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a layout onto what is live in this browser.
|
||||
*
|
||||
* Every live session and open web tab appears exactly once: stored refs keep
|
||||
* their group and stored order; anything the layout has not caught up with yet
|
||||
* (a session created a moment ago, a web tab opened on this device only) is
|
||||
* appended to the ungrouped section in the caller's order. Saved web tabs that
|
||||
* are not open here are skipped, as are refs to sessions that are gone.
|
||||
*
|
||||
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
|
||||
* tab, else the active session), so selecting a hidden session by keyboard,
|
||||
* palette or Alt+N never leaves the user with no visible selection.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
|
||||
* null when the layout has no groups: the caller renders the flat rail
|
||||
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
|
||||
* collapse hides (`hidden`); `sectionByRef` maps every placed row
|
||||
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!hasGroups(layout)) return null;
|
||||
const liveSessionIds = stableIds(options.liveSessionIds);
|
||||
const openWebviewIds = stableIds(options.openWebviewIds);
|
||||
const live = new Set(liveSessionIds);
|
||||
const open = new Set(openWebviewIds);
|
||||
const collapsed = new Set(asIds(options.collapsedGroupIds));
|
||||
const highlighted = options.activeWebviewId
|
||||
? `webview:${options.activeWebviewId}`
|
||||
: options.activeSessionId
|
||||
? `session:${options.activeSessionId}`
|
||||
: '';
|
||||
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
|
||||
const placed = new Set();
|
||||
const visibleRefs = [];
|
||||
const hiddenTabGroupByRef = {};
|
||||
const sectionByRef = {};
|
||||
const sections = [];
|
||||
|
||||
const place = (refs, sectionId, isCollapsed) => {
|
||||
const shown = [];
|
||||
const hidden = [];
|
||||
let count = 0;
|
||||
for (const ref of refs) {
|
||||
const key = refKey(ref);
|
||||
if (placed.has(key) || !renderable(ref)) continue;
|
||||
placed.add(key);
|
||||
sectionByRef[key] = sectionId;
|
||||
count++;
|
||||
const copy = { kind: ref.kind, id: ref.id };
|
||||
if (isCollapsed && key !== highlighted) {
|
||||
hiddenTabGroupByRef[key] = sectionId;
|
||||
hidden.push(copy);
|
||||
continue;
|
||||
}
|
||||
shown.push(copy);
|
||||
visibleRefs.push(copy);
|
||||
}
|
||||
return { shown, hidden, count };
|
||||
};
|
||||
|
||||
for (const group of layout.groups) {
|
||||
const isCollapsed = collapsed.has(group.id);
|
||||
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
|
||||
}
|
||||
const omissions = [
|
||||
...liveSessionIds.map((id) => ({ kind: 'session', id })),
|
||||
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
|
||||
];
|
||||
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
|
||||
if (ungrouped.count > 0) {
|
||||
sections.push({
|
||||
id: null,
|
||||
name: '',
|
||||
refs: ungrouped.shown,
|
||||
hidden: [],
|
||||
count: ungrouped.count,
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
|
||||
}
|
||||
|
||||
const ALERT_RANK = { action: 2, idle: 1 };
|
||||
|
||||
/**
|
||||
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
|
||||
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
|
||||
* selection, any expanded group) draws its own alert, so it is not counted
|
||||
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
|
||||
*/
|
||||
function hiddenGroupAlerts(projection, alertOf) {
|
||||
const result = {};
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
for (const section of sections) {
|
||||
if (section.id === null || !Array.isArray(section.hidden)) continue;
|
||||
let best = null;
|
||||
for (const ref of section.hidden) {
|
||||
if (ref.kind !== 'session') continue;
|
||||
const alert = alertOf(ref.id);
|
||||
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
|
||||
}
|
||||
if (best) result[section.id] = best;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
|
||||
* where, the headers' names, what a collapse hides), as opposed to a row's own
|
||||
* status/name/badges. The incremental render path only patches rows in place,
|
||||
* so a change here forces a full rebuild.
|
||||
*
|
||||
* Deliberately NOT the layout version: the server bumps it on every session
|
||||
* create/close and order PUT, and a bump that moves nothing visible must not
|
||||
* cost every client a full tab-strip rebuild. `layout` is accepted for
|
||||
* signature stability only.
|
||||
*/
|
||||
function structureKey(_layout, projection, collapsedGroupIds) {
|
||||
if (!projection) return null;
|
||||
return JSON.stringify({
|
||||
collapsed: stableIds(collapsedGroupIds).sort(),
|
||||
sections: projection.sections.map((section) => [
|
||||
section.id,
|
||||
section.name,
|
||||
section.count,
|
||||
section.refs.map(refKey),
|
||||
(section.hidden || []).map(refKey),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
|
||||
* `escapeHtml` is the caller's escaper. Group names are user content, so they
|
||||
* are escaped and marked `data-i18n-skip`.
|
||||
*
|
||||
* The caller makes the list itself the `tree` and marks rows up as treeitems
|
||||
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
|
||||
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
|
||||
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
|
||||
* (the rows sit below the header visually, not inside it).
|
||||
* - a COLLAPSED group owns nothing: the one row it still shows (the
|
||||
* selection) is a level-1 sibling, never the child of a closed node.
|
||||
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
|
||||
* so it is not announced as an expanded parent of an empty group.
|
||||
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
|
||||
* divider only, hidden from assistive tech, and its rows are not a group.
|
||||
*/
|
||||
function renderProjection(projection, renderRef, escapeHtml) {
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
return sections
|
||||
.map((section, index) => {
|
||||
const rows = section.refs.map((ref) => renderRef(ref)).join('');
|
||||
if (section.id === null) {
|
||||
return (
|
||||
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
|
||||
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
|
||||
);
|
||||
}
|
||||
const id = escapeHtml(section.id);
|
||||
const refsId = `tab-layout-group-refs-${index}`;
|
||||
const nameId = `tab-layout-group-name-${index}`;
|
||||
const leaf = section.count === 0;
|
||||
const expanded = !section.collapsed && !leaf;
|
||||
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
|
||||
return (
|
||||
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
||||
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
|
||||
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
||||
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span>` +
|
||||
// Pointer path to the group menu (right-click on the header works too).
|
||||
// Deliberately NOT a button and not focusable: a treeitem holds no
|
||||
// interactive children, and the keyboard path is Shift+F10 /
|
||||
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
|
||||
// the header's accessible name.
|
||||
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
|
||||
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">⋯</span></div>' +
|
||||
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-wins layout loading. A response that was overtaken by a later load is
|
||||
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
|
||||
* retry, replacing any retry already pending.
|
||||
*
|
||||
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
|
||||
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
|
||||
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
|
||||
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
|
||||
* any success resets the count.
|
||||
*/
|
||||
function createLoadCoordinator(options) {
|
||||
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
|
||||
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
|
||||
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let retryHandle = null;
|
||||
let failures = 0;
|
||||
const clearRetry = () => {
|
||||
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
|
||||
retryHandle = null;
|
||||
};
|
||||
const load = async () => {
|
||||
if (disposed) return false;
|
||||
const requestGeneration = ++generation;
|
||||
clearRetry();
|
||||
try {
|
||||
const layout = await options.fetchLayout();
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
failures = 0;
|
||||
options.applyLayout(layout);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
failures++;
|
||||
options.applyFallback();
|
||||
if (failures <= maxRetries) {
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
|
||||
retryHandle = options.scheduleRetry(() => load(), delay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
return {
|
||||
load,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
generation++;
|
||||
clearRetry();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Editing ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The browser edits through NAMED operations, not by diffing arrays: a write
|
||||
// that loses a version race (409) is rebased by replaying the same operations
|
||||
// on the layout the server returned, so a concurrent edit elsewhere survives.
|
||||
// The server stays the authority: it re-validates and normalizes every PUT.
|
||||
|
||||
function editError(message) {
|
||||
throw new Error(`Tab layout edit failed: ${message}`);
|
||||
}
|
||||
|
||||
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
|
||||
|
||||
function groupName(value) {
|
||||
const name = typeof value === 'string' ? value.trim() : '';
|
||||
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
|
||||
return name;
|
||||
}
|
||||
|
||||
function refLocations(layout) {
|
||||
return [
|
||||
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
|
||||
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
|
||||
];
|
||||
}
|
||||
|
||||
function containerRefs(layout, groupId) {
|
||||
if (groupId === null) return layout.ungrouped;
|
||||
const group = layout.groups.find((candidate) => candidate.id === groupId);
|
||||
if (!group) editError('unknown group');
|
||||
return group.refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that move together with `ref`: the session plus every descendant
|
||||
* that still follows its parent (non-manual, parent stored). Mirrors the
|
||||
* server's moveRef block so the optimistic rail matches what it will store.
|
||||
* `parents` maps a session id to its parent session id.
|
||||
*/
|
||||
function lineageBlock(layout, ref, parents) {
|
||||
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
|
||||
const children = new Map();
|
||||
for (const [childId, parentId] of Object.entries(parents || {})) {
|
||||
const child = stored.get(`session:${childId}`);
|
||||
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
|
||||
if (!children.has(parentId)) children.set(parentId, []);
|
||||
children.get(parentId).push(childId);
|
||||
}
|
||||
const keys = new Set();
|
||||
const visit = (key) => {
|
||||
if (keys.has(key)) return;
|
||||
keys.add(key);
|
||||
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
|
||||
};
|
||||
visit(refKey(ref));
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a moved row lands, as the server's `index` (counted AFTER the moved
|
||||
* block is taken out): before or after `anchor` in that container, or at its
|
||||
* end when there is no anchor.
|
||||
*/
|
||||
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const block = lineageBlock(layout, ref, parents);
|
||||
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
|
||||
// No anchor means "at the end", and an operation with no index keeps
|
||||
// meaning that when it is replayed onto a layout that has changed since.
|
||||
if (!anchor) return { groupId };
|
||||
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
|
||||
if (at < 0) return { groupId };
|
||||
return { groupId, index: placement === 'after' ? at + 1 : at };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a finished drag to ONE operation (or null for a drop that changes
|
||||
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
|
||||
*
|
||||
* source: { type: 'ref', ref } | { type: 'group', groupId }
|
||||
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
|
||||
* | { type: 'group', groupId } (a named group's header or empty body)
|
||||
* | { type: 'ungrouped' }
|
||||
*
|
||||
* A group dropped on another group (or any row in it) takes that group's slot;
|
||||
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
|
||||
* before/after it, on a header it is appended to that group.
|
||||
*/
|
||||
function dropOperation(layoutInput, source, target, parents) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!source || !target) return null;
|
||||
if (source.type === 'group') {
|
||||
const from = layout.groups.findIndex((group) => group.id === source.groupId);
|
||||
if (from < 0) return null;
|
||||
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
|
||||
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
|
||||
if (to < 0 || to === from) return null;
|
||||
return { type: 'reorderGroup', groupId: source.groupId, index: to };
|
||||
}
|
||||
if (source.type !== 'ref' || !validRef(source.ref)) return null;
|
||||
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
|
||||
if (!location) return null;
|
||||
let groupId;
|
||||
let anchor = null;
|
||||
let placement = 'before';
|
||||
if (target.type === 'ref' && validRef(target.ref)) {
|
||||
// Onto itself or onto a row that moves with it: nowhere to go.
|
||||
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
|
||||
groupId = target.groupId ?? null;
|
||||
anchor = target.ref;
|
||||
placement = target.placement === 'after' ? 'after' : 'before';
|
||||
} else if (target.type === 'group') {
|
||||
groupId = target.groupId ?? null;
|
||||
if (groupId === location.groupId) return null;
|
||||
} else if (target.type === 'ungrouped') {
|
||||
groupId = null;
|
||||
if (location.groupId === null) return null;
|
||||
} else return null;
|
||||
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
|
||||
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
|
||||
const operation = {
|
||||
type: 'moveRef',
|
||||
ref: { kind: source.ref.kind, id: source.ref.id },
|
||||
groupId: destination.groupId,
|
||||
...(destination.index === undefined ? {} : { index: destination.index }),
|
||||
parents: parents || {},
|
||||
};
|
||||
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one operation to a copy of the layout. Throws when the operation no
|
||||
* longer makes sense (an unknown group or row); a rebase drops that one
|
||||
* operation and keeps the rest. Replays are idempotent where it matters for
|
||||
* recovery: creating a group that already exists is a no-op.
|
||||
*/
|
||||
function applyOperation(layoutInput, operation) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const op = operation || {};
|
||||
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
|
||||
switch (op.type) {
|
||||
case 'createGroup': {
|
||||
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
|
||||
const name = groupName(op.name);
|
||||
if (layout.groups.some((group) => group.id === op.id)) return layout;
|
||||
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
|
||||
return layout;
|
||||
}
|
||||
case 'renameGroup':
|
||||
if (groupIndex < 0) editError('unknown group');
|
||||
layout.groups[groupIndex].name = groupName(op.name);
|
||||
return layout;
|
||||
case 'deleteGroup': {
|
||||
// Already gone (deleted elsewhere): nothing left to do.
|
||||
if (groupIndex < 0) return layout;
|
||||
const [removed] = layout.groups.splice(groupIndex, 1);
|
||||
layout.ungrouped.push(...removed.refs);
|
||||
return layout;
|
||||
}
|
||||
case 'reorderGroup': {
|
||||
if (groupIndex < 0) editError('unknown group');
|
||||
const [moved] = layout.groups.splice(groupIndex, 1);
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
|
||||
return layout;
|
||||
}
|
||||
case 'moveRef': {
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
const targetKey = refKey(op.ref);
|
||||
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
|
||||
const destinationId = op.groupId ?? null;
|
||||
containerRefs(layout, destinationId);
|
||||
const keys = lineageBlock(layout, op.ref, op.parents);
|
||||
const block = refLocations(layout)
|
||||
.filter((item) => keys.has(refKey(item.ref)))
|
||||
.map((item) => copyRef(item.ref));
|
||||
// A hand-moved child stops following its parent (server moveRef does the same).
|
||||
const head = block.find((item) => refKey(item) === targetKey);
|
||||
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
|
||||
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
|
||||
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
|
||||
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
|
||||
const destination = containerRefs(layout, destinationId);
|
||||
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
|
||||
return layout;
|
||||
}
|
||||
default:
|
||||
return editError(`unknown operation ${op.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Layout content without version metadata: equal keys mean "nothing to save". */
|
||||
function contentKey(layoutInput) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
return JSON.stringify([layout.groups, layout.ungrouped]);
|
||||
}
|
||||
|
||||
/** Replay operations, dropping (and counting) the ones that no longer apply. */
|
||||
function replayOperations(base, operations) {
|
||||
let layout = normalizeLayout(base);
|
||||
const kept = [];
|
||||
let dropped = 0;
|
||||
for (const operation of operations) {
|
||||
try {
|
||||
layout = applyOperation(layout, operation);
|
||||
kept.push(operation);
|
||||
} catch (_error) {
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
return { layout, kept, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized, optimistic writer for `PUT /api/tab-layout`.
|
||||
*
|
||||
* - enqueue() applies an operation at once (the rail repaints optimistically)
|
||||
* and schedules a flush; operations enqueued in the same turn share a PUT.
|
||||
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
|
||||
* sent on top of the version that write returns.
|
||||
* - A 409 carries the server's current layout: the in-flight operations are
|
||||
* replayed onto it and re-sent with its version (bounded attempts). A 400
|
||||
* (a row vanished between read and write) re-reads and rebases the same way.
|
||||
* - Anything else, or attempts exhausted, drops the batch and reports it; the
|
||||
* caller re-reads so the rail shows the server's truth.
|
||||
*
|
||||
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
|
||||
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
|
||||
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
|
||||
*/
|
||||
function createEditCoordinator(options) {
|
||||
let authoritative = normalizeLayout(options.initialLayout);
|
||||
let optimistic = authoritative;
|
||||
let pending = [];
|
||||
let inFlight = [];
|
||||
let writing = false;
|
||||
let timer = null;
|
||||
let disposed = false;
|
||||
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
|
||||
const cancel = options.cancel || ((handle) => clearTimeout(handle));
|
||||
const maxAttempts = options.maxAttempts || 3;
|
||||
const report = (message) => options.reportError?.(message);
|
||||
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
|
||||
const queue = () => {
|
||||
if (timer === null) timer = schedule(flush);
|
||||
};
|
||||
|
||||
async function flush() {
|
||||
timer = null;
|
||||
if (disposed || writing || pending.length === 0) return;
|
||||
writing = true;
|
||||
inFlight = pending;
|
||||
pending = [];
|
||||
let failed = false;
|
||||
let reportedDrop = false;
|
||||
let rereadFor400 = false;
|
||||
try {
|
||||
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
|
||||
const desired = replayOperations(authoritative, inFlight);
|
||||
inFlight = desired.kept;
|
||||
if (desired.dropped && !reportedDrop) {
|
||||
reportedDrop = true;
|
||||
report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||
}
|
||||
// Nothing left to change (dropped, or already true on the server).
|
||||
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
|
||||
inFlight = [];
|
||||
break;
|
||||
}
|
||||
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
|
||||
if (disposed) return;
|
||||
if (response?.ok && response.layout) {
|
||||
authoritative = normalizeLayout(response.layout);
|
||||
inFlight = [];
|
||||
} else if (response?.status === 409 && response.layout) {
|
||||
authoritative = normalizeLayout(response.layout);
|
||||
} else if (response?.status === 400 && options.fetchLayout && !rereadFor400) {
|
||||
// Maybe our base was stale in a way the server reports as invalid:
|
||||
// re-read once. A 400 that survives that is a refusal, not a race.
|
||||
rereadFor400 = true;
|
||||
authoritative = normalizeLayout(await options.fetchLayout());
|
||||
if (disposed) return;
|
||||
} else {
|
||||
throw new Error('Tab layout save failed');
|
||||
}
|
||||
}
|
||||
if (inFlight.length) {
|
||||
failed = true;
|
||||
report('Tab groups kept changing elsewhere; your edit was not saved.');
|
||||
}
|
||||
} catch (_error) {
|
||||
failed = true;
|
||||
report('Could not save tab groups.');
|
||||
} finally {
|
||||
inFlight = [];
|
||||
writing = false;
|
||||
if (!disposed) {
|
||||
const rebased = replayOperations(authoritative, pending);
|
||||
// Edits made while the write was in flight are rebased here, so one the
|
||||
// conflict made inapplicable is dropped here too, and says so (once).
|
||||
if (rebased.dropped && !failed && !reportedDrop) {
|
||||
report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||
}
|
||||
pending = rebased.kept;
|
||||
optimistic = rebased.layout;
|
||||
publish({ authoritative: true });
|
||||
if (failed) options.onFailure?.();
|
||||
if (pending.length) queue();
|
||||
else options.onSettled?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
|
||||
enqueue(operation) {
|
||||
optimistic = applyOperation(optimistic, operation);
|
||||
pending.push(operation);
|
||||
publish({ optimistic: true });
|
||||
queue();
|
||||
return normalizeLayout(optimistic);
|
||||
},
|
||||
/**
|
||||
* Re-apply operations recovered after a reload. Returns false (and queues
|
||||
* nothing) when the layout already reflects them, e.g. the keepalive save
|
||||
* landed before the page went away.
|
||||
*/
|
||||
restore(operations) {
|
||||
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
|
||||
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
|
||||
optimistic = replayed.layout;
|
||||
pending.push(...replayed.kept);
|
||||
publish({ optimistic: true });
|
||||
queue();
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Adopt a layout read from the server (SSE reload). Pending operations are
|
||||
* rebased onto it. Refused while a write is in flight (its result decides)
|
||||
* and for a layout older than the one already held.
|
||||
*/
|
||||
adoptExternal(layout) {
|
||||
if (disposed || writing) return false;
|
||||
const next = normalizeLayout(layout);
|
||||
if (next.version < authoritative.version) return false;
|
||||
authoritative = next;
|
||||
const rebased = replayOperations(next, pending);
|
||||
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||
pending = rebased.kept;
|
||||
optimistic = rebased.layout;
|
||||
publish({ authoritative: true, external: true });
|
||||
return true;
|
||||
},
|
||||
flush,
|
||||
isWriting: () => writing,
|
||||
hasPending: () => writing || pending.length > 0,
|
||||
/** Every operation not yet confirmed by the server, oldest first. */
|
||||
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
|
||||
baseVersion: () => authoritative.version,
|
||||
getLayout: () => normalizeLayout(optimistic),
|
||||
dispose() {
|
||||
disposed = true;
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = null;
|
||||
pending = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.CodemanTabLayout = {
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
project,
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
applyOperation,
|
||||
moveDestination,
|
||||
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||
dropOperation,
|
||||
contentKey,
|
||||
createEditCoordinator,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
MAX_GROUPS,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -298,9 +298,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeTabRailActionMenu(options = {}) {
|
||||
// The group menu borrows this class for its look but has its own owner
|
||||
// (closeTabGroupMenu); removing its DOM here would strand its listeners.
|
||||
const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)');
|
||||
const menu = document.querySelector('.tab-rail-action-menu');
|
||||
const trigger = this._tabRailActionMenuTrigger;
|
||||
menu?.remove();
|
||||
if (this._tabRailActionMenuOutside) {
|
||||
@@ -327,8 +325,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
|
||||
+601
-146
@@ -1,16 +1,592 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview Split-pane orchestration: opens a second live session
|
||||
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||
* a draggable divider, a session picker, and auto-collapse when either
|
||||
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency terminal-tile.js (window.TerminalTile)
|
||||
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
* giant synchronous write that blocks the main thread while parsing a long
|
||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||
* (terminal-ui.js): that one is wired into session-switch generation
|
||||
* counters and the live-output gate this simpler, independently
|
||||
* created/destroyed pane has no equivalent of.
|
||||
*/
|
||||
function writeChunked(terminal, buffer, isDestroyed) {
|
||||
if (!buffer) return Promise.resolve();
|
||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||
terminal.write(buffer);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
||||
// across the whole replay rather than just the fetch that precedes it.
|
||||
return new Promise((resolve) => {
|
||||
let offset = 0;
|
||||
const writeNext = () => {
|
||||
if (isDestroyed() || !terminal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||
offset += chunk.length;
|
||||
terminal.write(chunk);
|
||||
if (offset < buffer.length) {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||
else setTimeout(writeNext, 16);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
});
|
||||
}
|
||||
|
||||
class SplitTerminalPane {
|
||||
constructor(sessionId, mountEl, opts = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.mountEl = mountEl;
|
||||
this.sessionMode = opts.mode;
|
||||
this.fontSettings = opts.fontSettings || {};
|
||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||
// detaching this session AFTER the split is already open must still be
|
||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||
// fight the split picker already refuses to open at pick time.
|
||||
this.detachedSessions = opts.detachedSessions;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._wsClosed = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
|
||||
// are held there with their arrival time instead of written under it.
|
||||
this._historyPullAt = 0;
|
||||
this._historyPullUseless = false;
|
||||
this._liveQueue = null;
|
||||
this._onWheel = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this._installWheelListener();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Pane B has no gates of its own by default, so every app-level chord
|
||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||
// registry-aware predicates so a rebind or a disable restores plain
|
||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
||||
// to, so intercepting it here would only break paste.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
if (
|
||||
ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||
// swallow — in a plain shell session this is the user's own
|
||||
// job-control tool and must reach the PTY, but in every other mode
|
||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||
// loop dead. Pane B has its own PTY/session and must not send a
|
||||
// suspend into a non-shell one just because the primary pane's own
|
||||
// gate lives elsewhere.
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey &&
|
||||
this.sessionMode !== 'shell'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||
// newline from a submit. Without this gate, Pane B's onData would
|
||||
// send that bare \r straight over the WS and submit an incomplete
|
||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||
// session (this.sessionId), never the primary pane's
|
||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
}).catch(() => {
|
||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||
// explicit, never-falls-through copy chord, and the predicate above
|
||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||
// a branch that assigns c.key only for a couple of special cases
|
||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||
// interrupt byte reaching the PTY (verified live: it does not).
|
||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||
// Re-implemented against this.terminal rather than reusing
|
||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||
// and would copy the wrong pane's selection.
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
// Both clean options are read for THIS pane, never the primary one:
|
||||
// the gutter width comes from this.sessionId's own run mode, and the
|
||||
// partial-first-line flag from this terminal's own selection range.
|
||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
||||
// same split and the same keystroke.
|
||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
||||
const selection = isColumnSelection
|
||||
? raw
|
||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
}) ?? raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void global.app._copyText?.(selection).then((ok) => {
|
||||
this.terminal?.clearSelection?.();
|
||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying — clear for feedback (a padding-only
|
||||
// selection cleans to '' and this press still falls through to the
|
||||
// PTY as 0x03, matching the primary pane's own rule).
|
||||
if (this.terminal?.hasSelection?.()) {
|
||||
this.terminal.clearSelection?.();
|
||||
global.app.showToast?.('Nothing to copy', 'warning');
|
||||
}
|
||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load existing scrollback before going live. The WS below is
|
||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||
// 'terminal' events), so without this Pane B stays blank until the
|
||||
// target session happens to produce new output. It LOOKED
|
||||
// intermittent rather than always-broken because _sendResize() below
|
||||
// often nudges the shared session's real tmux window to a new size,
|
||||
// and tmux repaints its current screen on resize — that repaint was
|
||||
// getting captured and streamed here, incidentally populating the
|
||||
// pane. When Pane B's computed dimensions happened to already match
|
||||
// the session's last-known size, Session.resize() (session.ts) skips
|
||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||
// The await covers the whole chunked replay, not just the fetch, so a
|
||||
// live frame from the socket below can never land in the middle of it.
|
||||
await this._loadBuffer();
|
||||
if (this._destroyed) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._wsReady = true;
|
||||
this._sendResize();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this._onLiveOutput(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this._onLiveClear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
||||
// buffer loader for the same reason connect() does.
|
||||
this._refreshBuffer();
|
||||
}
|
||||
} catch {
|
||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
||||
// closed socket per the WebSocket spec (no exception, no log). No
|
||||
// reconnect logic here — Pane B is deliberately plainer than the
|
||||
// primary pane (see the fileoverview above); a drop just stops
|
||||
// resizing until the parent recreates the pane. But onData already
|
||||
// silently drops keystrokes while _wsReady is false (below), so
|
||||
// without a visible marker a dropped socket left Pane B looking
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => this._onSocketClosed();
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// The socket's close, split out of connect() so the tests can drive it.
|
||||
// While a history pull is running the marker waits for the pull's finally
|
||||
// block: written now, it would sit above the output the pull is still
|
||||
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
|
||||
// land in the middle of a chunked replay.
|
||||
_onSocketClosed() {
|
||||
this._wsReady = false;
|
||||
this._wsClosed = true;
|
||||
if (!this._liveQueue) this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Extracted so both _onSocketClosed() and a history pull that ends on a
|
||||
// closed socket can write it (see _pullHistory()'s finally block).
|
||||
_writeDisconnectedMarker() {
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||
// terminal, neither of which applies to this independent pane, so this is
|
||||
// a standalone equivalent rather than a call into it.
|
||||
//
|
||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||
// get one full replay. `fetch` here goes through the global wrapper
|
||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||
// WebSocket URL above, which does not.
|
||||
//
|
||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
||||
// (writeChunked resolves after its last chunk), so two replays can never
|
||||
// interleave their chunks into one terminal. A second call while one is
|
||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
||||
// a trailing re-run instead.
|
||||
async _loadBuffer() {
|
||||
if (this._bufferLoading) return;
|
||||
this._bufferLoading = true;
|
||||
try {
|
||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
if (payload.terminalBuffer && this.terminal) {
|
||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
||||
_endBufferLoad() {
|
||||
this._bufferLoading = false;
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Live terminal output. Written straight through, except while a history
|
||||
// pull is replaying: a capture is current only up to the instant tmux took
|
||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
||||
// _finishBufferLoad `since` rule), never written underneath it.
|
||||
_onLiveOutput(data) {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
||||
else this.terminal?.write(data);
|
||||
}
|
||||
|
||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||
_onLiveClear() {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||
else this.terminal?.clear();
|
||||
}
|
||||
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
||||
// xterm keeps doing the scrolling.
|
||||
_installWheelListener() {
|
||||
this._onWheel = (ev) => {
|
||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||
};
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
||||
// so this pane's xterm ends up with about one screen of scrollback while
|
||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
||||
// history was unreachable. The primary pane has the same pull
|
||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
||||
// tmux the browser xterm never enters the alternate buffer.
|
||||
_maybeLoadMoreHistory() {
|
||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
||||
if (this._bufferLoading) return;
|
||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
||||
// _sendResize(): a detached session's own window already owns its PTY
|
||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const active = this.terminal.buffer.active;
|
||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
||||
// rather than latch; a pull that could only have downgraded the pane
|
||||
// waits far longer.
|
||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
||||
const now = Date.now();
|
||||
if (now - this._historyPullAt < cooldown) return;
|
||||
this._historyPullAt = now;
|
||||
void this._pullHistory();
|
||||
}
|
||||
|
||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
||||
// main thread) and replays it under the reader's current place. Holds the
|
||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||
async _pullHistory() {
|
||||
// A close before the pull already wrote its marker; one during it did not.
|
||||
const closedBefore = this._wsClosed;
|
||||
this._bufferLoading = true;
|
||||
this._liveQueue = [];
|
||||
let replayed = false;
|
||||
let capturedAt = 0;
|
||||
try {
|
||||
// A deadline, because live output is held for as long as this runs: a
|
||||
// request that hangs would otherwise freeze the whole pane. Aborting
|
||||
// lands in the catch below, which releases the flag and the queue. It
|
||||
// covers the body read too, not just the headers.
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
|
||||
});
|
||||
// The cutoff below is the response's arrival, the same `since` rule the
|
||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||
// for the instant tmux took the capture, which lies somewhere in the
|
||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||
// by one round trip and not closable without a server-side capture time.
|
||||
capturedAt = performance.now();
|
||||
const payload = (await res.json())?.data;
|
||||
const buffer = payload?.terminalBuffer;
|
||||
const term = this.terminal;
|
||||
if (!buffer || !term || this._destroyed) return;
|
||||
const rowsBefore = term.buffer.active.length;
|
||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
||||
// lines, so a window of short lines can carry more rows than this pane
|
||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
||||
const scrollbackCap = term.options?.scrollback || 0;
|
||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
||||
// Nothing to gain (this also covers a downgrade, which would delete
|
||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
||||
// untruncated window IS all of tmux's history and the next burst can add
|
||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
||||
// what the pane shows, and every ask costs the server a capture-pane of
|
||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
||||
// off too, since no window can ever fit in it.
|
||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
||||
return;
|
||||
}
|
||||
this._historyPullUseless = false;
|
||||
term.write('\x1bc');
|
||||
replayed = true;
|
||||
await writeChunked(term, buffer, () => this._destroyed);
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// xterm parses asynchronously: an empty write's callback fires only
|
||||
// after everything before it, so the row count below is the settled one.
|
||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
||||
// rows down; land there and the recovered history sits above it.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
} catch {
|
||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||
} finally {
|
||||
const queued = this._liveQueue ?? [];
|
||||
this._liveQueue = null;
|
||||
// After a replay, only frames that arrived after the capture are news;
|
||||
// earlier ones are already in it. With no replay, every held frame is.
|
||||
const cutoff = replayed ? capturedAt : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < cutoff) continue;
|
||||
if (entry.clear) this.terminal?.clear();
|
||||
else this.terminal?.write(entry.data);
|
||||
}
|
||||
// A replay's own `\x1bc` wipes a marker written before the pull,
|
||||
// painting a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket, so re-stamp it
|
||||
// after a replay. A close DURING the pull wrote no marker at all
|
||||
// (_onSocketClosed() defers it while the queue is live), so write it
|
||||
// whether or not this pull replayed. Checked after the queue flush so
|
||||
// it is the last thing on screen, matching what the close would have
|
||||
// left had the pull never run.
|
||||
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
||||
// and no further frame is coming to correct stale content.
|
||||
_refreshBuffer() {
|
||||
if (this._bufferLoading) {
|
||||
this._bufferRefreshPending = true;
|
||||
return;
|
||||
}
|
||||
this.terminal?.clear();
|
||||
void this._loadBuffer();
|
||||
}
|
||||
|
||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.localFit();
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this._wsReady || !this.fitAddon) return;
|
||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||
// to its own window AFTER this split was opened, so its own window now
|
||||
// owns the PTY's size and Pane B must stand aside.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (!dims) return;
|
||||
// Send the real proposed dimensions unclamped, matching the primary
|
||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
||||
// divider's own reachable 20% floor position, causing real
|
||||
// output-wrapping bugs.
|
||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this._onWheel) {
|
||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||
this._onWheel = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
// onclose fires asynchronously AFTER close(); without this it ran
|
||||
// its "disconnected" write against a pane already torn down.
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
this.fitAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
global.SplitTerminalPane = SplitTerminalPane;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||
@@ -46,9 +622,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
event?.stopPropagation();
|
||||
// The tile grid and the split are never open together (tile-grid.js); the
|
||||
// Split button shows as unavailable meanwhile.
|
||||
if (this._tilesOwnTerminal?.()) return;
|
||||
if (this._splitPane) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
@@ -142,8 +715,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// Never beside the tile grid: the main terminal is parked while it is open.
|
||||
if (this._tilesOwnTerminal?.()) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
@@ -158,7 +729,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// B's own session tab while split can otherwise land here with
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||
// resize frame. Refuse before creating any DOM or TerminalTile.
|
||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
||||
if (sessionId === this.activeSessionId) return;
|
||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||
// re-applied here: the menu can sit open while a listed session's CLI
|
||||
@@ -184,38 +755,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const paneB = document.createElement('div');
|
||||
paneB.className = 'terminal-pane-b';
|
||||
// Pane B's header: the harness logo, the name and the model, as on a grid
|
||||
// tile, and the close button at a tile button's size.
|
||||
const headerB = this._buildSplitPaneHeader();
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'tile-btn tile-remove terminal-pane-b-close';
|
||||
close.title = 'Close split';
|
||||
close.setAttribute('aria-label', 'Close split');
|
||||
close.textContent = '\u00D7';
|
||||
close.addEventListener('click', () => this.closeSplitPane());
|
||||
headerB.el.appendChild(close);
|
||||
const bodyB = document.createElement('div');
|
||||
bodyB.className = 'terminal-pane-b-container';
|
||||
paneB.append(headerB.el, bodyB);
|
||||
// Pane A is the main terminal, which has no header of its own: while the
|
||||
// split is open it gets the same strip, so each session in the split view
|
||||
// names its harness and model. It takes height from the main terminal,
|
||||
// which the opening resize below fits through syncTerminalGeometry (#464);
|
||||
// closeSplitPane gives it back.
|
||||
const headerA = this._buildSplitPaneHeader();
|
||||
headerA.el.classList.add('terminal-pane-a-header');
|
||||
this._splitHeaders = { a: headerA, b: headerB };
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
`;
|
||||
|
||||
parent.insertBefore(container, wrap);
|
||||
wrap.insertBefore(headerA.el, wrap.firstChild);
|
||||
container.appendChild(wrap);
|
||||
wrap.style.flexBasis = '50%';
|
||||
container.appendChild(divider);
|
||||
container.appendChild(paneB);
|
||||
paneB.style.flexBasis = '50%';
|
||||
|
||||
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||
mode: session?.mode,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
@@ -225,7 +780,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
this._renderSplitChrome();
|
||||
|
||||
// Pane A just went from full width to 50%, but nothing has told its
|
||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||
@@ -253,9 +807,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = null;
|
||||
this._splitSessionId = null;
|
||||
// Before the refit below, so the main terminal gets its full height back.
|
||||
this._splitHeaders?.a.el.remove();
|
||||
this._splitHeaders = null;
|
||||
this._updateSplitButtonState(false);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
@@ -266,16 +817,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
parent.insertBefore(wrap, container);
|
||||
container.remove();
|
||||
|
||||
// Pane A takes the whole width back and, with its header strip gone, its
|
||||
// whole height: through syncTerminalGeometry (#464), never a bare
|
||||
// fitAddon.fit(). sendResize fits that way as its first step.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
||||
// while activeSessionId is still the id the server just removed, so a
|
||||
// resize from here would be aimed at a session that no longer exists;
|
||||
// the promoted session gets its own resize from selectSession(), and the
|
||||
// terminal is only refitted here.
|
||||
if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
|
||||
else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
// the promoted session gets its own resize from selectSession().
|
||||
if (!options.skipPrimaryResize) {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
// A click on .btn-split does one of two things — open the picker, or
|
||||
@@ -283,92 +832,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||
/**
|
||||
* A split pane's header strip: the harness logo, the session name and the
|
||||
* model, the three a grid tile's header shows. Built from nodes (the name
|
||||
* and the model are untrusted text) and painted by _renderSplitChrome.
|
||||
*/
|
||||
_buildSplitPaneHeader() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'terminal-pane-b-header';
|
||||
const harness = document.createElement('span');
|
||||
harness.className = 'split-harness run-mode-dot';
|
||||
harness.setAttribute('role', 'img');
|
||||
const title = document.createElement('span');
|
||||
title.className = 'split-title';
|
||||
const name = document.createElement('span');
|
||||
// `.session-name` is one of the translator's skipped surfaces (user text).
|
||||
name.className = 'session-name';
|
||||
// As on a tile: the name inside is never translated, the tooltip may be,
|
||||
// and screen readers hear the model once, in the logo's accessible name.
|
||||
const model = document.createElement('span');
|
||||
model.className = 'split-model';
|
||||
model.setAttribute('aria-hidden', 'true');
|
||||
model.hidden = true;
|
||||
const modelName = document.createElement('span');
|
||||
modelName.setAttribute('data-i18n-skip', '');
|
||||
model.appendChild(modelName);
|
||||
title.append(name, model);
|
||||
el.append(harness, title);
|
||||
return { el, harness, name, model, modelName };
|
||||
},
|
||||
|
||||
/**
|
||||
* Both split headers from their sessions: Pane A shows the active session,
|
||||
* Pane B its own. Runs after every tab render, so a rename or a model
|
||||
* change reaches them; unchanged values write nothing.
|
||||
*/
|
||||
_renderSplitChrome() {
|
||||
const headers = this._splitHeaders;
|
||||
if (!headers || !this._splitPane) return;
|
||||
for (const [parts, id] of [
|
||||
[headers.a, this.activeSessionId],
|
||||
[headers.b, this._splitSessionId],
|
||||
]) {
|
||||
const session = id ? this.sessions.get(id) : null;
|
||||
if (!session) continue;
|
||||
const name = this.getSessionName?.(session) || session.name || 'Session';
|
||||
if (parts.nameValue !== name) {
|
||||
parts.nameValue = name;
|
||||
parts.name.textContent = name;
|
||||
}
|
||||
this._paintSessionHarness(parts, session, 'split-harness');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Paints a session header's harness logo and model: a grid tile's, and the
|
||||
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
|
||||
* data, never a branch), the model is text (describeSessionHarness,
|
||||
* constants.js). Diffs against the values it last wrote, kept on `parts`,
|
||||
* never against the DOM, which the translator may have rewritten: an
|
||||
* unchanged session writes nothing, and this runs on every tab render.
|
||||
*
|
||||
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
|
||||
* header's nodes (the model's box and the name inside it); the memo lives here too
|
||||
* @param {object} session - the session the header shows
|
||||
* @param {string} logoClass - the header's own class for its logo
|
||||
*/
|
||||
_paintSessionHarness(parts, session, logoClass) {
|
||||
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
|
||||
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
|
||||
if (parts.harnessClass !== cls) {
|
||||
parts.harnessClass = cls;
|
||||
parts.harness.className = cls;
|
||||
}
|
||||
if (parts.harnessTitle !== harness.title) {
|
||||
parts.harnessTitle = harness.title;
|
||||
parts.harness.title = harness.title;
|
||||
parts.harness.setAttribute('aria-label', harness.title);
|
||||
parts.model.title = harness.title;
|
||||
}
|
||||
if (parts.modelValue !== harness.model) {
|
||||
parts.modelValue = harness.model;
|
||||
parts.modelName.textContent = harness.model;
|
||||
parts.model.hidden = !harness.model;
|
||||
}
|
||||
},
|
||||
|
||||
_updateSplitButtonState(open) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
@@ -389,9 +852,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
|
||||
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
|
||||
// fifty of each dragging across half a wide viewport.
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// The split can auto-collapse mid-drag (the other pane's session
|
||||
@@ -527,15 +991,6 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||
// dimensions via its own `{t:'z',...}` resize frame.
|
||||
// Every tab render (any session change: a rename, a model switch) refreshes
|
||||
// the split headers too, the way tile-grid.js refreshes the tile headers.
|
||||
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
|
||||
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
|
||||
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
|
||||
this._renderSplitChrome?.();
|
||||
return result;
|
||||
};
|
||||
|
||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+52
-431
@@ -55,9 +55,6 @@
|
||||
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
|
||||
// focus itself, and the accessory bar, which is built to be used while the
|
||||
// keyboard is open.
|
||||
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
|
||||
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
|
||||
// headers are listed by role instead, or tapping one would drop the keyboard.
|
||||
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
|
||||
'input',
|
||||
'textarea',
|
||||
@@ -67,7 +64,6 @@
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="treeitem"]',
|
||||
'.keyboard-accessory-bar',
|
||||
'.path-picker-overlay',
|
||||
].join(',');
|
||||
@@ -254,235 +250,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._keyCode229Recovery = null;
|
||||
},
|
||||
|
||||
_destroyMobileImePreview() {
|
||||
try {
|
||||
this._mobileImePreview?.destroy?.();
|
||||
} catch {
|
||||
// The preview is visual-only; terminal replacement must continue.
|
||||
}
|
||||
this._mobileImePreview = null;
|
||||
this._mobileImePreviewSessionId = null;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreviewNode?.remove?.();
|
||||
} catch {
|
||||
// Best-effort node cleanup only.
|
||||
}
|
||||
try {
|
||||
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
|
||||
} catch {
|
||||
// Best-effort ownership cleanup only.
|
||||
}
|
||||
this._mobileImePreviewNode = null;
|
||||
this._mobileImePreviewHelpers = null;
|
||||
try {
|
||||
if (this._mobileImePreviewOfflineHandler) {
|
||||
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
|
||||
}
|
||||
if (this._mobileImePreviewPagehideHandler) {
|
||||
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort listener cleanup only.
|
||||
}
|
||||
this._mobileImePreviewOfflineHandler = null;
|
||||
this._mobileImePreviewPagehideHandler = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
|
||||
* text an IME is composing inside the terminal, so the user types blind.
|
||||
*
|
||||
* Two homes, chosen per render:
|
||||
* - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
|
||||
* cursor stays at the prompt start, under the overlay's opaque text (z 7,
|
||||
* `.xterm-screen`). So the overlay draws the composition itself, as an
|
||||
* underlined tail after its pending text (`setComposition`).
|
||||
* - Otherwise (a shell, or the overlay could not place it): a span inside
|
||||
* `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
|
||||
* the helper textarea, which follow the PTY cursor.
|
||||
*
|
||||
* Visual only: nothing here touches the input path, and every failure
|
||||
* leaves no DOM behind.
|
||||
*/
|
||||
_initMobileImePreview() {
|
||||
this._destroyMobileImePreview();
|
||||
let preview = null;
|
||||
let helpers = null;
|
||||
try {
|
||||
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
|
||||
const textarea = this.terminal?.textarea;
|
||||
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
|
||||
if (!textarea || !helpers) return;
|
||||
|
||||
preview = document.createElement('span');
|
||||
this._mobileImePreviewNode = preview;
|
||||
this._mobileImePreviewHelpers = helpers;
|
||||
preview.className = 'codeman-ime-preview';
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
preview.hidden = true;
|
||||
helpers.appendChild(preview);
|
||||
const syncPreviewTypography = () => {
|
||||
try {
|
||||
const compositionView =
|
||||
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
|
||||
if (!compositionView || !preview.style) return;
|
||||
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
|
||||
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
|
||||
const value = style?.[property] || compositionView.style?.[property];
|
||||
if (value) preview.style[property] = value;
|
||||
}
|
||||
const theme = this.terminal?.options?.theme;
|
||||
let foreground = theme?.foreground;
|
||||
let background = theme?.background;
|
||||
if (!foreground || !background) {
|
||||
try {
|
||||
const current = window.codemanCurrentXtermTheme?.();
|
||||
foreground = foreground || current?.foreground;
|
||||
background = background || current?.background;
|
||||
} catch {
|
||||
// Theme lookup is best-effort; retain the safe terminal fallback.
|
||||
}
|
||||
}
|
||||
preview.style.color = foreground || '#e0e0e0';
|
||||
// Opaque, like xterm's own composition view, so the preview does not
|
||||
// overprint whatever sits at the cursor (a dim composer placeholder).
|
||||
preview.style.backgroundColor = background || '#0d0d0d';
|
||||
} catch {
|
||||
// Typography matching is visual-only and must not block input.
|
||||
}
|
||||
};
|
||||
// The overlay only when it is what shows typed text right now (local echo
|
||||
// on, and not handed back to plain PTY echo by a composer nav key).
|
||||
const localEchoOverlay = () =>
|
||||
this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
|
||||
? this._localEchoOverlay || null
|
||||
: null;
|
||||
const clearOverlayComposition = () => {
|
||||
try {
|
||||
if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
|
||||
} catch {}
|
||||
};
|
||||
const hideSpan = () => {
|
||||
try {
|
||||
preview.hidden = true;
|
||||
} catch {}
|
||||
try {
|
||||
preview.textContent = '';
|
||||
} catch {}
|
||||
try {
|
||||
delete preview.dataset.phase;
|
||||
} catch {}
|
||||
try {
|
||||
helpers.classList.remove('codeman-ime-preview-owned');
|
||||
} catch {}
|
||||
};
|
||||
const clearPreview = () => {
|
||||
clearOverlayComposition();
|
||||
hideSpan();
|
||||
};
|
||||
const controller = MobileImePreview.create({
|
||||
textarea,
|
||||
// An ancestor of the textarea: its capture-phase keydown listener runs
|
||||
// before xterm's capture listener on the textarea, which finalizes the
|
||||
// composition and emits the commit synchronously.
|
||||
keydownTarget: this.terminal.element,
|
||||
render: ({ text, phase }) => {
|
||||
try {
|
||||
const overlay = localEchoOverlay();
|
||||
if (overlay && typeof overlay.setComposition === 'function') {
|
||||
overlay.setComposition(text);
|
||||
// No prompt found = nothing drawn: fall back to the span.
|
||||
if (!text || overlay.state?.visible) {
|
||||
hideSpan();
|
||||
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
|
||||
return;
|
||||
}
|
||||
overlay.setComposition('');
|
||||
} else {
|
||||
clearOverlayComposition();
|
||||
}
|
||||
syncPreviewTypography();
|
||||
preview.textContent = text;
|
||||
preview.dataset.phase = phase;
|
||||
preview.hidden = !text;
|
||||
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
|
||||
} catch {
|
||||
clearPreview();
|
||||
}
|
||||
},
|
||||
clear: clearPreview,
|
||||
});
|
||||
this._mobileImePreview = controller;
|
||||
this._mobileImePreviewSessionId = this.activeSessionId;
|
||||
|
||||
// Offline and pagehide only reset: initTerminal() runs once per page
|
||||
// load, so destroying on pagehide would leave the preview off for good
|
||||
// after a back-forward cache restore (iOS Safari keeps pages there).
|
||||
this._mobileImePreviewOfflineHandler = () => {
|
||||
try {
|
||||
this._mobileImePreview?.reset?.();
|
||||
} catch {
|
||||
// Disconnect cleanup is visual-only.
|
||||
}
|
||||
};
|
||||
this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
|
||||
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
|
||||
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
|
||||
} catch {
|
||||
this._destroyMobileImePreview();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tell the IME preview about a chunk xterm emitted through onData. Returns
|
||||
* true when the chunk is the IME's committed text, in which case the preview
|
||||
* holds it (phase 'committed') until something else shows it. Never throws.
|
||||
*/
|
||||
_consumeMobileImeTerminalData(data) {
|
||||
let isImeCommit = false;
|
||||
try {
|
||||
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
|
||||
} catch {
|
||||
// The preview is visual-only; normal terminal input must continue.
|
||||
}
|
||||
// Output accepted from here on can carry the echo of this commit.
|
||||
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
|
||||
return isImeCommit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear a committed IME preview once terminal output accepted AFTER the
|
||||
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
|
||||
* written flush covered (null when part of it was deferred), so output that
|
||||
* was already queued before the commit can never clear it early.
|
||||
*/
|
||||
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
|
||||
try {
|
||||
const commitSeq = this._mobileImeCommitOutputSeq;
|
||||
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
|
||||
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
this._mobileImePreview?.noteAuthoritativeOutput?.();
|
||||
} catch {
|
||||
// Authoritative output is never delayed or consumed by the preview.
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The local echo overlay has just taken a committed IME chunk through the
|
||||
* ordinary printable/paste branch, so it now shows the text: release the
|
||||
* preview instead of waiting for terminal output.
|
||||
*/
|
||||
_transferMobileImeCommitToLocalEcho() {
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreview?.completeCommit?.({ predicted: true });
|
||||
} catch {
|
||||
// Ownership transfer is visual-only.
|
||||
}
|
||||
},
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
@@ -540,13 +307,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._initMobileImePreview();
|
||||
this._installMobileTapMouseGuard();
|
||||
this._installShiftDragSelection();
|
||||
this._installTouchSelectionFocusGuard();
|
||||
// Focus coming back to the primary terminal ends a second pane's claim on
|
||||
// the keyboard (see _focusedPane).
|
||||
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
|
||||
|
||||
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||
@@ -590,11 +353,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler
|
||||
// has already acted on one that applies, and its preventDefault() does not
|
||||
// stop xterm. Every event type, and BEFORE the Shift+Enter branch below.
|
||||
if (this.tileShortcutFor?.(ev)) return false;
|
||||
|
||||
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
||||
// ^C. With NO selection the branch must fall through (return true, and no
|
||||
// preventDefault) or the interrupt key is lost, which is the whole reason
|
||||
@@ -689,11 +447,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
// which the inner application recognizes as "insert newline" vs carriage return.
|
||||
// This handler also runs for keypress/keyup: xterm drops a keypress carrying Ctrl/Alt
|
||||
// but NOT one carrying only Shift, so unless every event type is swallowed here,
|
||||
// Shift+Enter's keypress sends a bare \r (submit) after the newline. Only keydown sends.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
||||
if (ev.type === 'keydown' && this.activeSessionId) {
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
if (this.activeSessionId) {
|
||||
if (this._localEchoEnabled) {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
@@ -1333,9 +1088,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Same yield as sendResize: never resize a PTY whose session is showing
|
||||
// in its own window. Dragging the dashboard's border must not reshape it.
|
||||
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
||||
// The tile grid parks the main terminal: its session is sized by its
|
||||
// tile, which this same timer refits below (_forEachTile).
|
||||
const tilesOwnTerminal = this._tilesOwnTerminal?.();
|
||||
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
|
||||
// This block used to fit unconditionally and skip only the SIGWINCH,
|
||||
// which is the one combination that cannot be right: it moves xterm to
|
||||
@@ -1343,9 +1095,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// repaints from the shape it was told. Withhold both, or neither —
|
||||
// a reflow nothing is rendering for buys nothing and costs correctness.
|
||||
const dims =
|
||||
this.activeSessionId && !keyboardUp && !detachedElsewhere && !tilesOwnTerminal
|
||||
? this._geometryForResizeRequest()
|
||||
: null;
|
||||
this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null;
|
||||
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
||||
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
||||
// and xterm does not hold — the write-only guess this whole change exists
|
||||
@@ -1420,19 +1170,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// has to re-resolve their gate before the redraw, not just move them.
|
||||
this.applyLineageLineSettings?.();
|
||||
this.updateConnectionLines();
|
||||
// Unguarded on purpose: hasPending excludes an IME composition, so a
|
||||
// composition-only overlay would stay on the old prompt row. rerender()
|
||||
// is a no-op when the overlay has nothing to draw.
|
||||
this._localEchoOverlay?.rerender();
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// Pane B (split view) has its own container and its own fit()/resize
|
||||
// frame — this observer only ever measured Pane A's container, so
|
||||
// without this call Pane B never learned about a window resize, an
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// stayed at whatever size it was last dragged to. Grid tiles are left
|
||||
// out: the grid's own observer (tile-grid.js _scheduleTileGridRefit)
|
||||
// refits every one of them on the same resize, and a second fit here
|
||||
// only re-measured six panes to send nothing.
|
||||
this._forEachTile?.((tile) => tile.fit(), { grid: false });
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||
};
|
||||
|
||||
@@ -1466,8 +1212,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
const handleTerminalData = (data) => {
|
||||
// Before anything can rewrite `data`: is this chunk the IME's commit?
|
||||
const isImeCommit = this._consumeMobileImeTerminalData(data);
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1545,8 +1289,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// When enabled, keystrokes are buffered locally in the overlay for
|
||||
// instant visual feedback. Nothing is sent to the PTY until Enter
|
||||
// (or a control char) is pressed — avoids out-of-order char delivery.
|
||||
// An IME commit takes the same printable/paste branch as typed text,
|
||||
// and the overlay then shows it in place of the preview.
|
||||
if (this._localEchoEnabled && !echoPassthrough) {
|
||||
if (data === '\x7f') {
|
||||
const source = this._localEchoOverlay?.removeChar();
|
||||
@@ -1603,7 +1345,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (data.length > 1 && data.charCodeAt(0) >= 32) {
|
||||
// Paste: append to overlay only (sent on Enter)
|
||||
this._localEchoOverlay?.appendText(data);
|
||||
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
|
||||
return;
|
||||
}
|
||||
if (data.charCodeAt(0) < 32) {
|
||||
@@ -1749,7 +1490,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
// Printable char: add to overlay only (sent on Enter)
|
||||
this._localEchoOverlay?.addChar(data);
|
||||
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1843,24 +1583,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in terminal output and makes them clickable.
|
||||
* When clicked, opens a floating log viewer window with live streaming.
|
||||
*
|
||||
* `target` defaults to the primary terminal and the active session. A second
|
||||
* terminal (the split pane) passes its own `{ terminal, getSessionId,
|
||||
* setHovered }`, so a path printed there opens against THAT pane's session and
|
||||
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
|
||||
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
|
||||
* Returns the provider.
|
||||
*/
|
||||
registerFilePathLinkProvider(target = {}) {
|
||||
registerFilePathLinkProvider() {
|
||||
const self = this;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const getSessionId = target.getSessionId || (() => this.activeSessionId);
|
||||
const setHovered =
|
||||
target.setHovered ||
|
||||
((hovered) => {
|
||||
this._linkHovered = hovered;
|
||||
});
|
||||
const isPrimary = terminal === this.terminal;
|
||||
|
||||
// Debug: Track if provider is being invoked
|
||||
let lastInvokedLine = -1;
|
||||
@@ -1873,7 +1598,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
||||
}
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const buffer = self.terminal.buffer.active;
|
||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
@@ -1897,7 +1622,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||
buffer,
|
||||
bufferLineNumber - 1,
|
||||
terminal.cols,
|
||||
self.terminal.cols,
|
||||
MAX_STITCHED_ROWS
|
||||
);
|
||||
if (!logical) {
|
||||
@@ -1950,10 +1675,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
self._linkHovered = true;
|
||||
},
|
||||
leave() {
|
||||
setHovered(false);
|
||||
self._linkHovered = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2009,18 +1734,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
const sessionId = getSessionId();
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||
self.openFilePreview(text, sessionId);
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, sessionId);
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
self._linkHovered = true;
|
||||
},
|
||||
leave() {
|
||||
setHovered(false);
|
||||
self._linkHovered = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2063,11 +1787,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// produce), so the tap path asks this SAME provider what is under the finger
|
||||
// rather than growing a second, driftable copy of the patterns.
|
||||
// See _terminalLinkAtPoint.
|
||||
if (isPrimary) this._terminalLinkProvider = provider;
|
||||
terminal.registerLinkProvider(provider);
|
||||
this._terminalLinkProvider = provider;
|
||||
this.terminal.registerLinkProvider(provider);
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
return provider;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -3432,7 +3155,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||
const effort = this.getEffortSetting(globalSettings);
|
||||
const advisorModel = this.getAdvisorSetting(globalSettings);
|
||||
// `resumeSessionId` is a Claude conversation UUID (server reads it from
|
||||
// ~/.claude/projects); an external-CLI row has no such thing, so sending
|
||||
// it there gets silently ignored while the OMITTED `mode` field defaults
|
||||
@@ -3482,8 +3204,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
...modeConfig,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
// The advisor is a claude-only feature; other CLIs would carry it inertly.
|
||||
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
|
||||
}),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
@@ -3802,9 +3522,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
batchTerminalWrite(data) {
|
||||
// Arrival order of output, so the IME preview can tell output that
|
||||
// followed a commit from output that was already queued before it.
|
||||
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
|
||||
// Feed the renderer watchdog. Recorded before the buffer-load early return
|
||||
// below: a write that is queued rather than written still means the pipeline
|
||||
// owes us a frame once it drains.
|
||||
@@ -3868,7 +3585,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Accumulate raw data (may contain DEC 2026 markers)
|
||||
this.pendingWrites.push(data);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
@@ -3900,7 +3616,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Transfer buffered data to normal pending writes
|
||||
this.pendingWrites.push(this.flickerFilterBuffer);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this.flickerFilterBuffer = '';
|
||||
this.flickerFilterActive = false;
|
||||
|
||||
@@ -3931,15 +3646,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Position is tracked dynamically by _findPrompt() on every render.
|
||||
*/
|
||||
_updateLocalEchoState() {
|
||||
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
|
||||
this._mobileImePreviewSessionId = this.activeSessionId;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreview?.reset?.();
|
||||
} catch {
|
||||
// The preview is visual-only; session switching must continue.
|
||||
}
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
@@ -4145,9 +3851,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
|
||||
deferred = true;
|
||||
}
|
||||
// Newest output this chunk fully contains, for the IME preview. A split
|
||||
// chunk may not hold that output yet, so it reports nothing.
|
||||
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
|
||||
this._terminalWriteInFlight = true;
|
||||
this._terminalWriteInFlightBytes = writeChunk.length;
|
||||
try {
|
||||
@@ -4165,7 +3868,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// because the test's write mock moved the viewport synchronously.)
|
||||
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
|
||||
});
|
||||
} catch (err) {
|
||||
this._terminalWriteInFlight = false;
|
||||
@@ -4195,10 +3897,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Re-position local echo overlay after terminal writes — Ink redraws can
|
||||
// move the ❯ prompt to a different row, making the overlay invisible.
|
||||
// Unguarded on purpose: hasPending excludes an IME composition, so a
|
||||
// composition-only overlay (the first word of a prompt) would otherwise
|
||||
// stay on the old row. rerender() is a no-op when there is nothing to draw.
|
||||
this._localEchoOverlay?.rerender();
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
|
||||
// After Tab completion: detect the completed text in the overlay.
|
||||
// Use terminal.write('', callback) to defer detection until xterm.js
|
||||
@@ -4513,56 +4214,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Terminal Controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
|
||||
*
|
||||
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
||||
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
||||
* primary pane. It answers with the pane whose terminal was focused LAST, not
|
||||
* with `document.activeElement`: clicking the mic or a header button moves
|
||||
* DOM focus to that button, and the dictation it starts still belongs to the
|
||||
* pane the user was typing in. A second pane (the split pane's Pane B) claims
|
||||
* it from its own terminal's focus; the primary terminal's focus gives it back.
|
||||
*/
|
||||
_focusedPane() {
|
||||
let tile = this._focusedTile;
|
||||
// With the tile grid open the main terminal is parked, so the pane is the
|
||||
// focused tile even when DOM focus sits on a button or a panel.
|
||||
if ((!tile || tile._destroyed || !tile.terminal) && this._tilesOwnTerminal?.()) {
|
||||
tile = this._tileFor(this.activeSessionId);
|
||||
}
|
||||
if (tile && !tile._destroyed && tile.terminal) {
|
||||
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
|
||||
}
|
||||
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
|
||||
},
|
||||
|
||||
/** Record which second pane holds the keyboard (null: the primary terminal). */
|
||||
_noteFocusedTile(tile) {
|
||||
this._focusedTile = tile || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Run `fn(tile)` for every secondary terminal pane on screen: the split
|
||||
* pane's second terminal and every tile of the tile grid (never both: the two
|
||||
* modes are not open together). Font, weight, family and skin changes go
|
||||
* through here so they reach every pane without a special case per pane kind.
|
||||
* `{ grid: false }` skips grid tiles (they keep their own font size).
|
||||
* Agent Teams terminals size themselves and are not tiles.
|
||||
*/
|
||||
_forEachTile(fn, { grid = true } = {}) {
|
||||
if (this._splitPane?.terminal) fn(this._splitPane);
|
||||
if (!grid || !this._tileGrid?.open) return;
|
||||
for (const { tile } of this._tileGrid.tiles.values()) {
|
||||
if (tile.terminal) fn(tile);
|
||||
}
|
||||
},
|
||||
|
||||
// Clears the pane the keyboard is in. The chord itself also reaches that
|
||||
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
|
||||
// in the same pane whose display is cleared, never a different one.
|
||||
clearTerminal() {
|
||||
this._focusedPane().terminal?.clear();
|
||||
this.terminal.clear();
|
||||
},
|
||||
|
||||
/** Insert editable text at the active prompt without pressing Enter. */
|
||||
@@ -4626,21 +4279,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
||||
*/
|
||||
async restoreTerminalSize() {
|
||||
// A second pane owns its own geometry: refit it and force its PTY to the
|
||||
// size it renders at (TerminalTile.fit), whatever another device set.
|
||||
const pane = this._focusedPane();
|
||||
if (!pane.isPrimary) {
|
||||
pane.tile.fit({ force: true });
|
||||
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('No active session', 'warning');
|
||||
return;
|
||||
}
|
||||
// Backstop: _focusedPane() answers with the focused tile while the grid is
|
||||
// open, so this is reached only if that tile is gone mid-call.
|
||||
if (this._tilesOwnTerminal?.()) return;
|
||||
|
||||
// The pane belongs to the popup showing it, so this window has nothing to
|
||||
// restore. Say so rather than reporting a size that was never sent — the
|
||||
@@ -4731,18 +4373,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||
*/
|
||||
cleanedTerminalSelection(text, target = {}) {
|
||||
// `target` names a second terminal (the split pane) and its session; both
|
||||
// default to the primary pane, whose `this.terminal` this file otherwise reads.
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
|
||||
cleanedTerminalSelection(text) {
|
||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
if (!raw) return '';
|
||||
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
const range = this._normalisedSelectionRange(terminal);
|
||||
const range = this._normalisedSelectionRange();
|
||||
return clean(raw, {
|
||||
margin: this._cliGutterColumns(target.sessionId),
|
||||
margin: this._cliGutterColumns(),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
@@ -4812,11 +4451,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
async copyTerminalSelection(text, target = {}) {
|
||||
// Every terminal touched below is the TARGET one: clearing or refocusing the
|
||||
// primary after copying from the split pane would hit the wrong pane.
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const selection = this.cleanedTerminalSelection(text, target);
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = this.cleanedTerminalSelection(text);
|
||||
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||
// or a shell submits the line. decideAutoCopy applies the same rule.
|
||||
@@ -4825,7 +4461,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// selection, so a padding-only selection left set can no longer swallow a
|
||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||
// clear avoids is a highlight that sits there having copied nothing.
|
||||
terminal?.clearSelection?.();
|
||||
this.terminal?.clearSelection?.();
|
||||
this.showToast('Nothing to copy', 'warning');
|
||||
return false;
|
||||
}
|
||||
@@ -4833,14 +4469,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ok) {
|
||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||
// drops the selection on any keypress, so this matches existing feel).
|
||||
terminal.clearSelection?.();
|
||||
this.terminal.clearSelection?.();
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
} else {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
}
|
||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||
// is the CJK-aware focus router, not xterm's raw focus().
|
||||
terminal.focus();
|
||||
this.terminal.focus();
|
||||
return ok;
|
||||
},
|
||||
|
||||
@@ -5769,20 +5405,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
increaseFontSize() {
|
||||
// With the tile grid open, Ctrl +/- sizes the tiles (their own font size).
|
||||
if (this._tilesOwnTerminal?.()) {
|
||||
this.setTileFontSize(Math.min(this._tileGridFontSize() + 2, 24));
|
||||
return;
|
||||
}
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
this.setFontSize(Math.min(current + 2, 24));
|
||||
},
|
||||
|
||||
decreaseFontSize() {
|
||||
if (this._tilesOwnTerminal?.()) {
|
||||
this.setTileFontSize(Math.max(this._tileGridFontSize() - 2, 10));
|
||||
return;
|
||||
}
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
this.setFontSize(Math.max(current - 2, 10));
|
||||
},
|
||||
@@ -5795,13 +5422,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
this._forEachTile?.(
|
||||
(tile) => {
|
||||
tile.terminal.options.fontSize = size;
|
||||
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||
},
|
||||
{ grid: false }
|
||||
);
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5827,10 +5451,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontFamily = resolved;
|
||||
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||
});
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5882,11 +5506,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontWeight = fontWeight;
|
||||
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||
});
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -6105,9 +5729,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// settle-time refit through this call and has no fallback, which is correct:
|
||||
// a pane it does not own is not its to refit either.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||
// Backstop: while the tile grid owns the terminal, a tile sizes this PTY and
|
||||
// the parked main terminal measures nothing worth sending.
|
||||
if (this._tilesOwnTerminal?.()) return false;
|
||||
// Fit, floor, and apply in one step so the numbers below are the numbers
|
||||
// xterm is actually holding (or, while another device holds the width,
|
||||
// the numbers this container would hold if the PTY followed).
|
||||
@@ -6358,13 +5979,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
tile.terminal.options.theme = { ...theme };
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -554,11 +554,6 @@ const VoiceInput = {
|
||||
_analyserSource: null, // MediaStreamSource for level meter
|
||||
_audioContext: null, // AudioContext for level meter
|
||||
_levelAnimFrame: null, // rAF handle for level meter
|
||||
// The session dictation was started FOR, captured in start(). Transcripts
|
||||
// arrive seconds later and the green send button / compose overlay can be
|
||||
// used later still; reading app.activeSessionId at that point sent the text
|
||||
// to whatever tab the user had switched to in the meantime.
|
||||
_targetSessionId: null,
|
||||
|
||||
init() {
|
||||
this._initRecognition();
|
||||
@@ -668,12 +663,10 @@ const VoiceInput = {
|
||||
|
||||
start() {
|
||||
if (this.isRecording) return;
|
||||
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
|
||||
if (!target) {
|
||||
if (!app.activeSessionId) {
|
||||
app.showToast('No active session', 'warning');
|
||||
return;
|
||||
}
|
||||
this._targetSessionId = target;
|
||||
this._retryCount = 0;
|
||||
|
||||
const provider = this._resolveProvider();
|
||||
@@ -966,31 +959,8 @@ const VoiceInput = {
|
||||
this.stop();
|
||||
},
|
||||
|
||||
/** The session this dictation belongs to (see _targetSessionId). */
|
||||
_targetSession() {
|
||||
return this._targetSessionId || app.activeSessionId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send text to the dictation's own session. The active session keeps going
|
||||
* through app.sendInput() exactly as before; any other session goes straight
|
||||
* to the durable queue with the same useMux flag sendInput() passes.
|
||||
*/
|
||||
_sendToTarget(target, text) {
|
||||
if (target === app.activeSessionId) return app.sendInput(text);
|
||||
// Closed while dictating: say so instead of queueing text for a session
|
||||
// that will only answer 404 (and never typing it into some other tab).
|
||||
if (app.sessions && !app.sessions.has(target)) {
|
||||
app.showToast?.('That session has closed; dictation not sent', 'warning');
|
||||
return Promise.resolve();
|
||||
}
|
||||
app._sendInputAsync(target, text, { useMux: true });
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
_insertText(text) {
|
||||
const target = this._targetSession();
|
||||
if (!target || !text.trim()) return;
|
||||
if (!app.activeSessionId || !text.trim()) return;
|
||||
const trimmed = text.trim();
|
||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||
|
||||
@@ -1005,17 +975,14 @@ const VoiceInput = {
|
||||
this._showComposeOverlay(trimmed);
|
||||
}
|
||||
} else {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||
// The overlay belongs to the ACTIVE session's terminal, so text dictated
|
||||
// for any other session must not be typed into it.
|
||||
const isActive = target === app.activeSessionId;
|
||||
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
app._localEchoOverlay.appendText(trimmed);
|
||||
} else {
|
||||
this._sendToTarget(target, trimmed).catch(() => {});
|
||||
app.sendInput(trimmed).catch(() => {});
|
||||
}
|
||||
this._showVoiceSendBtn();
|
||||
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
|
||||
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1041,15 +1008,10 @@ const VoiceInput = {
|
||||
|
||||
// Click handler
|
||||
this._voiceSendHandler = () => {
|
||||
const target = this._targetSession();
|
||||
if (!target) return;
|
||||
if (!app.activeSessionId) return;
|
||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||
// predictions belong to the ACTIVE session's terminal, so a dictation
|
||||
// for another session just sends its Enter there.
|
||||
if (target !== app.activeSessionId) {
|
||||
this._sendToTarget(target, '\r').catch(() => {});
|
||||
} else if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
// otherwise just send \r directly to the PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
const text = app._localEchoOverlay.pendingText || '';
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection();
|
||||
@@ -1103,7 +1065,7 @@ const VoiceInput = {
|
||||
const send = () => {
|
||||
const val = textarea.value.trim();
|
||||
overlay.remove();
|
||||
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
|
||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
||||
};
|
||||
const cancel = () => overlay.remove();
|
||||
const newInput = () => {
|
||||
|
||||
@@ -304,26 +304,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
if (!this.webviews.get(id)) continue;
|
||||
parts.push(this.renderWebviewTab(id, idx));
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
|
||||
* The grouped vertical rail places single web tabs into their group with this,
|
||||
* so a web tab's markup is the same in every layout.
|
||||
*/
|
||||
renderWebviewTab(id, idx) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return '';
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
@@ -335,7 +322,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span></span>
|
||||
</div>`;
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
@@ -358,7 +348,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
this._syncTabTreeSelection?.(container);
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
* session record involved. A dropped plan therefore returns the user to
|
||||
* resuming by hand, one at a time, which is where they are without this
|
||||
* feature. What the plan held that a transcript does not is the owner, the
|
||||
* name, the env overrides, the effort, the model, the advisor model and the
|
||||
* lineage.
|
||||
* name, the env overrides, the effort and the lineage.
|
||||
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
|
||||
* import and no IO, which keeps it unit-testable and cycle-free.
|
||||
* - Spending is take-then-build: `take()` removes entries synchronously, before
|
||||
|
||||
+12
-142
@@ -50,8 +50,6 @@ import {
|
||||
} from '../../git-clone.js';
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { prepareNewCasePath } from '../case-path.js';
|
||||
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
|
||||
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||
import {
|
||||
@@ -165,9 +163,6 @@ function gitDiagnosticLine(stderr: string): string {
|
||||
* the clone response says so out loud instead of silently merging into them.
|
||||
*/
|
||||
function repoShipsClaudeSettings(casePath: string): boolean {
|
||||
// Deliberately NOT the bounded path probe: the tree was just cloned into the
|
||||
// local case space (and lstat'ed synchronously moments ago), so a bound protects
|
||||
// nothing here, while a probe answering "unknown" could silently drop this warning.
|
||||
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
||||
}
|
||||
|
||||
@@ -271,7 +266,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: casePath,
|
||||
hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')),
|
||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
|
||||
});
|
||||
@@ -286,21 +281,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
if (admin) {
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue;
|
||||
const state = await probePath(path);
|
||||
if (state === 'absent') continue;
|
||||
// An unreachable linked case (a dead network mount) stays listed and says
|
||||
// so: dropping it would read as "deleted" and invite a same-name local case.
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
...(state === 'unknown' ? { unreachable: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
|
||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
@@ -342,7 +333,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const dockerCaseInfo: CaseInfo = {
|
||||
name: dockerCase.name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
@@ -433,109 +424,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { cases: summaries } };
|
||||
});
|
||||
|
||||
/**
|
||||
* `POST /api/cases` with a `path`: create the folder (or fill an EMPTY existing one), scaffold it
|
||||
* exactly like a normal case, and register it in the linked-cases registry so it lists, resolves
|
||||
* and deletes (unlinks, never removes files) like any linked case. Everything is judged before
|
||||
* anything is written; a failure after the first write undoes what this call created.
|
||||
*/
|
||||
async function createCaseInCustomFolder(
|
||||
name: string,
|
||||
description: string | undefined,
|
||||
customPath: string,
|
||||
req: FastifyRequest,
|
||||
reply: { code: (n: number) => unknown }
|
||||
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
|
||||
const ownCasesDir = resolveCasesDir(getAuthUser(req));
|
||||
if (existsSync(join(ownCasesDir, name))) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
`Case "${name}" is already linked to ${linkedCases[name]}`
|
||||
);
|
||||
}
|
||||
|
||||
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
|
||||
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
|
||||
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
|
||||
if (!prepared.ok) {
|
||||
// A parent that did not answer is not a bad request: OPERATION_FAILED (422), like
|
||||
// POST /api/sessions for a workingDir on a dead mount.
|
||||
if (prepared.code === 'UNREACHABLE') {
|
||||
reply.code(422);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, prepared.reason);
|
||||
}
|
||||
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
|
||||
reply.code(status);
|
||||
const code =
|
||||
prepared.code === 'NOT_FOUND'
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: prepared.code === 'EXISTS'
|
||||
? ApiErrorCode.ALREADY_EXISTS
|
||||
: ApiErrorCode.INVALID_INPUT;
|
||||
return createErrorResponse(code, prepared.reason);
|
||||
}
|
||||
const casePath = prepared.path;
|
||||
const alreadyAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
|
||||
if (alreadyAs) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${alreadyAs}"`);
|
||||
}
|
||||
|
||||
const made: string[] = [];
|
||||
try {
|
||||
if (!prepared.existedEmpty) {
|
||||
mkdirSync(casePath);
|
||||
made.push(casePath);
|
||||
}
|
||||
mkdirSync(join(casePath, 'src'));
|
||||
made.push(join(casePath, 'src'));
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
|
||||
made.push(join(casePath, 'CLAUDE.md'));
|
||||
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
|
||||
// Re-read right before writing, so a case linked since the check above is not dropped. This only
|
||||
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
|
||||
// requests that both read before either writes can still lose one entry.
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
return { success: true, data: { case: { name, path: casePath } } };
|
||||
} catch (err) {
|
||||
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
|
||||
// entries inside the empty folder the user picked; never anything else.
|
||||
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
|
||||
if ((err as { conflict?: boolean }).conflict) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/cases', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
// A custom folder writes outside the cases directory and registers the path in the shared,
|
||||
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
|
||||
if (customPath !== undefined) {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
return createCaseInCustomFolder(name, description, customPath, req, reply);
|
||||
}
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
@@ -1728,7 +1618,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return {
|
||||
name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
@@ -1743,32 +1633,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||
|
||||
// NOT_FOUND means DEFINITELY absent: the Run button creates a case on it, so
|
||||
// a path that merely did not answer (a dead network mount) must never get it.
|
||||
// One path, asked for explicitly: probe it even while unrelated mounts are dead.
|
||||
const state = await probePath(casePath, { pastCap: true });
|
||||
if (state === 'absent') {
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||
}
|
||||
if (state === 'unknown') {
|
||||
// The linked registry knows where the case lives, so say where, and that
|
||||
// it is not answering. A local case has no such record to fall back on.
|
||||
if (!linked) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
describeUnknownPath('Case folder', casePath, { pastCap: true })
|
||||
);
|
||||
}
|
||||
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
|
||||
}
|
||||
|
||||
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||
return {
|
||||
name,
|
||||
path: casePath,
|
||||
// Probed like the folder above, or a healthy case reads as having no CLAUDE.md under the cap.
|
||||
hasClaudeMd: (await probePath(join(casePath, 'CLAUDE.md'), { pastCap: true })) === 'present',
|
||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||
...(linked && { linked: true }),
|
||||
};
|
||||
});
|
||||
@@ -1786,11 +1660,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||
|
||||
const fixPlanState = await probePath(fixPlanPath, { pastCap: true });
|
||||
if (fixPlanState === 'unknown') {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Case folder is not responding or not readable');
|
||||
}
|
||||
if (fixPlanState === 'absent') {
|
||||
if (!existsSync(fixPlanPath)) {
|
||||
return { exists: false, content: null, todos: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
|
||||
* tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
|
||||
*
|
||||
* The probe engine is synchronous (`which` + `<bin> --version` per tool, each up to its own
|
||||
* timeout), so it must never run on the server's event loop: a handful of slow probes would
|
||||
* freeze every request and every SSE client, with the process still alive. The default runner
|
||||
* therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
|
||||
* parses its output; the runner is injected so tests never spawn anything.
|
||||
*
|
||||
* Read-only, but the report names install paths and versions on the host, so in multi-user
|
||||
* mode it is admin only (the same bar as the other host-introspection routes).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
|
||||
import type { DependencyReportJson } from '../../utils/dependency-report.js';
|
||||
|
||||
export type DoctorRunner = (category?: string) => Promise<DependencyReportJson>;
|
||||
|
||||
const DOCTOR_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isReport(v: unknown): v is DependencyReportJson {
|
||||
const r = v as Partial<DependencyReportJson> | null;
|
||||
return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
|
||||
* and still prints the report, so a non-zero exit with parseable stdout is a normal result.
|
||||
*/
|
||||
export const defaultDoctorRunner: DoctorRunner = (category) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
...process.execArgv,
|
||||
process.argv[1],
|
||||
'doctor',
|
||||
'--json',
|
||||
...(category ? ['--category', category] : []),
|
||||
];
|
||||
execFile(
|
||||
process.execPath,
|
||||
args,
|
||||
{ timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
|
||||
(err, stdout) => {
|
||||
if (err && (err as { killed?: boolean }).killed) {
|
||||
return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`));
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(stdout);
|
||||
if (isReport(parsed)) return resolve(parsed);
|
||||
} catch {
|
||||
/* fall through to the error below */
|
||||
}
|
||||
reject(err ?? new Error('doctor produced no report'));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
|
||||
// Each run forks a full Node process, so two tabs or a script must not stack them: callers
|
||||
// asking for the same category while one is in flight share its promise.
|
||||
const inFlight = new Map<string, Promise<DependencyReportJson>>();
|
||||
const runShared = (category?: string): Promise<DependencyReportJson> => {
|
||||
const key = category ?? '';
|
||||
let running = inFlight.get(key);
|
||||
if (!running) {
|
||||
running = runner(category).finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, running);
|
||||
}
|
||||
return running;
|
||||
};
|
||||
app.get(
|
||||
'/api/doctor',
|
||||
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<DependencyReportJson>> => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
const { category } = req.query as { category?: string };
|
||||
if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
return { success: true, data: await runShared(category) };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
|
||||
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
|
||||
* an overview: the enclosing repository, or each repository found below a folder that holds several
|
||||
* projects (see `getGitWorkspaceOverview` for exactly which).
|
||||
*
|
||||
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
|
||||
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
|
||||
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
|
||||
* `findSessionOrFail`, like every session-scoped route.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { redactGitCredentials } from '../../git-clone.js';
|
||||
import { readDockerCases } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import {
|
||||
emptyOverview,
|
||||
findWorkspaceRepo,
|
||||
getGitFileDiff,
|
||||
getGitWorkspaceOverview,
|
||||
getGitWorkspaceStatus,
|
||||
type GitFileDiff,
|
||||
type GitFileKind,
|
||||
type GitRunner,
|
||||
type GitWorkspaceOverview,
|
||||
} from '../../git-workspace-status.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
|
||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
|
||||
export function registerGitStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort,
|
||||
git?: GitRunner,
|
||||
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
|
||||
): void {
|
||||
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { fresh } = req.query as { fresh?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||
return {
|
||||
success: true,
|
||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||
git,
|
||||
fresh: fresh === '1',
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
|
||||
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
|
||||
// the route cannot be pointed at an arbitrary directory or file. The repository is checked against
|
||||
// the overview's own (cached) list with the Docker roots as they are now, and only that one
|
||||
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
||||
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote || session.docker) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||
}
|
||||
const repoRoot = repo
|
||||
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
|
||||
: null;
|
||||
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
|
||||
const entry =
|
||||
status?.state === 'ok'
|
||||
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
||||
: undefined;
|
||||
if (!status?.repoRoot || !entry) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
||||
}
|
||||
try {
|
||||
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INTERNAL_ERROR,
|
||||
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
@@ -29,9 +28,6 @@ export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* @fileoverview MCP server sync (src/mcp-sync.ts).
|
||||
*
|
||||
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
|
||||
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
|
||||
*
|
||||
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
|
||||
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
|
||||
* is admin only. A second apply while one is running answers 409. Responses carry server names
|
||||
* only, never env values, headers or file content (a parse failure is reported by position).
|
||||
*
|
||||
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
|
||||
* or already has its config file; one that is enabled but absent from the machine is reported
|
||||
* `absent` and never created. Its file is located with this process's env (the env the CLIs
|
||||
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type McpSyncResult,
|
||||
} from '../../types.js';
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { enabledClis } from '../../config/cli-registry/registry.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
|
||||
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
|
||||
|
||||
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
|
||||
export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.mcpSyncEnabled === true;
|
||||
}
|
||||
|
||||
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
|
||||
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
||||
return enabledClis()
|
||||
.filter((e) => e.capabilities.mcpConfig)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
...e.capabilities.mcpConfig!,
|
||||
installed: isCliEntryInstalled(e, availability),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
|
||||
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
|
||||
*/
|
||||
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
|
||||
return enabledClis()
|
||||
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
|
||||
.map((e) => e.label);
|
||||
}
|
||||
|
||||
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
if (!(await readMcpSyncEnabled())) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerMcpSyncRoutes(app: FastifyInstance): void {
|
||||
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
|
||||
const denied = await gate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
try {
|
||||
const availability = await probeStockCliAvailability();
|
||||
const targets = mcpSyncTargets(availability);
|
||||
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
if (err instanceof McpSyncBusyError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
|
||||
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
|
||||
}
|
||||
@@ -293,7 +293,6 @@ export function registerRalphRoutes(
|
||||
planItems,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
} = parseBody(RalphLoopStartSchema, req.body);
|
||||
|
||||
// Multi-user: cases live in the requesting user's space.
|
||||
@@ -344,7 +343,6 @@ export function registerRalphRoutes(
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
owner: rlOwner,
|
||||
});
|
||||
|
||||
|
||||
@@ -195,8 +195,6 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
|
||||
),
|
||||
effort: saved.effort,
|
||||
model: saved.model,
|
||||
advisorModel: saved.advisorModel,
|
||||
attachmentHistory:
|
||||
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
|
||||
saved.attachmentHistory,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user