mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffaa5ee80c | ||
|
|
6aecc3b858 | ||
|
|
470cf79776 | ||
|
|
06c4c7da16 | ||
|
|
7917273188 | ||
|
|
b451b3851e | ||
|
|
6d6e7da481 | ||
|
|
52267f8617 | ||
|
|
36af183f97 | ||
|
|
c029cea620 | ||
|
|
b8038a592c | ||
|
|
ccd6df893f | ||
|
|
e082d8e438 | ||
|
|
fa53a5751e | ||
|
|
23d145b121 | ||
|
|
5724e0c8b4 | ||
|
|
9649b5019b | ||
|
|
ec2a036543 | ||
|
|
c197e9370b | ||
|
|
d887002ca8 | ||
|
|
574f3db58b | ||
|
|
17a976fa2e | ||
|
|
7064b3c1d5 | ||
|
|
01f403dc1b | ||
|
|
2cf37529e9 | ||
|
|
df398c5c68 | ||
|
|
d68a173a23 | ||
|
|
0ff57ce304 | ||
|
|
f39c66e4e8 | ||
|
|
4398dbfad0 | ||
|
|
c9a5fdab00 | ||
|
|
7616de13de | ||
|
|
af032fc81a | ||
|
|
41a10b159e | ||
|
|
e6b258fc44 | ||
|
|
98c6c1881d | ||
|
|
9240493c43 | ||
|
|
7cbce5bf6c | ||
|
|
3df113fc54 | ||
|
|
0ae39cdd94 | ||
|
|
45db24bacf | ||
|
|
4123d229f4 | ||
|
|
f776ad87b6 | ||
|
|
846c62fbf7 | ||
|
|
73c0bfccc4 | ||
|
|
3af1ff6fae | ||
|
|
988f111cd0 | ||
|
|
5f5de5827e | ||
|
|
0aba9f6ec8 | ||
|
|
61dbd97ba4 | ||
|
|
b5d8122ea7 | ||
|
|
57f7a77573 | ||
|
|
af4cc45cfd | ||
|
|
01eb8ef08a | ||
|
|
140ca35e2d | ||
|
|
17232b01f6 | ||
|
|
612c69d57a | ||
|
|
5e27043bf7 | ||
|
|
ee1a155e2c | ||
|
|
2d96472dbe | ||
|
|
e0542bb172 |
@@ -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.33.2",
|
||||
"version": "1.34.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# aicodeman
|
||||
|
||||
## 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
|
||||
|
||||
- f776ad8: ### Thanks
|
||||
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
|
||||
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
|
||||
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
|
||||
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
|
||||
|
||||
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
|
||||
|
||||
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
|
||||
|
||||
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
|
||||
|
||||
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
|
||||
|
||||
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
|
||||
|
||||
## 1.33.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -78,7 +78,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.33.2 (must match `package.json`)
|
||||
**Version**: 1.34.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -137,7 +137,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
|
||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
||||
@@ -244,6 +245,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
@@ -254,7 +257,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
@@ -270,7 +273,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
@@ -295,6 +298,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: only an IN-WORKSPACE `.md` path clicked in the TERMINAL keeps the tail viewer (live follow); the Files panel, chat paths, out-of-workspace terminal paths and attachment cards all reach `openFilePreview()` and render it. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
@@ -317,7 +322,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
@@ -333,7 +338,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
@@ -365,7 +370,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
@@ -377,7 +382,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
@@ -428,7 +433,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -31,8 +32,12 @@ 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/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -713,6 +713,41 @@ 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.
|
||||
|
||||
## 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
@@ -118,6 +118,10 @@ 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:
|
||||
@@ -249,6 +253,14 @@ 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.
|
||||
|
||||
@@ -338,6 +338,42 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
|
||||
codeman status | list | attach <path> codeman doctor
|
||||
```
|
||||
|
||||
### Opening a session from your own page
|
||||
|
||||
To send someone from your page to one session, link to the dashboard with the
|
||||
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
|
||||
dashboard selects that tab when it loads. It also removes the fragment from its
|
||||
own URL, so a later link to the same session still counts as a change.
|
||||
|
||||
Keep reusing one named window to make later links fast:
|
||||
|
||||
```js
|
||||
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
|
||||
```
|
||||
|
||||
When that window already shows the dashboard, only the fragment differs. The
|
||||
browser therefore keeps the page loaded, and the dashboard switches tabs without
|
||||
reloading it. A session the window has shown before appears at once. A session
|
||||
your page has only just created may not be listed yet, so the dashboard waits
|
||||
for its `session:created` event and selects it then. That wait lasts at most 30
|
||||
seconds: a link whose session never appears (a closed session, a typo, or in
|
||||
multi-user mode another user's session) is dropped with a "Session not found"
|
||||
notice. Clicking another tab, going Home or opening a web tab also ends the
|
||||
wait, so a session that turns up later never takes the screen from the person.
|
||||
|
||||
When your page holds the window reference (`const win = window.open(...)`),
|
||||
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
|
||||
without a reload, but adds no history entry, so Back in the dashboard window
|
||||
does not turn into a silent no-op.
|
||||
|
||||
Following a link does not count as someone looking at the session, so it
|
||||
leaves the session's idle alert in place. The alert clears when the person
|
||||
clicks the tab or types into the session. A link to a session that is popped
|
||||
out into its own window asks that window to come forward, as clicking its tab does.
|
||||
|
||||
A link to `/session/<id>` opens a page showing that session alone, and that
|
||||
page loads from scratch for every link.
|
||||
|
||||
## Seam 4: Hooks
|
||||
|
||||
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
||||
|
||||
@@ -532,6 +532,16 @@ 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 |
|
||||
|
||||
@@ -40,6 +40,10 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||
is kept working for the bundled UI.
|
||||
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
|
||||
`#session=<id>` fragment selects that session if this client can see it. The
|
||||
fragment name and that meaning are stable; see
|
||||
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
|
||||
|
||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||
|
||||
|
||||
@@ -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, and ultracode controls | Yes | No |
|
||||
| Model, effort, advisor, 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,6 +96,9 @@ 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
|
||||
|
||||
@@ -144,6 +144,8 @@ 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)
|
||||
|
||||
@@ -6,15 +6,16 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## Tab alerts
|
||||
|
||||
@@ -60,6 +61,51 @@ 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
|
||||
@@ -152,7 +198,8 @@ 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.
|
||||
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.
|
||||
3. Approvals Inbox on.
|
||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||
[Keeping Agents Running](Keeping-Agents-Running).
|
||||
@@ -162,7 +209,8 @@ from the lock screen.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -50,6 +50,7 @@ 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
|
||||
|
||||
@@ -87,13 +88,22 @@ every session or only the active tab.
|
||||
|
||||
### Models
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -112,11 +122,13 @@ 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, and the idle
|
||||
threshold that decides when a quiet session counts as needing you. See
|
||||
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||
[Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -13,7 +13,8 @@ It renders what it can:
|
||||
|
||||
| Kind | Behaviour |
|
||||
| ------------------------ | ------------------------------------------------------------------------- |
|
||||
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
|
||||
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
||||
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
||||
| Images | Inline. |
|
||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||
@@ -85,8 +86,9 @@ File paths in a session are links. That works in two places:
|
||||
render as underlined monospace links.
|
||||
|
||||
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
||||
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
|
||||
the tail viewer instead, which follows a file that is still being written.
|
||||
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
|
||||
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
|
||||
viewer instead, which follows a file that is still being written.
|
||||
|
||||
Paths **outside** the session's workspace work too, which matters because that is where most
|
||||
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
||||
|
||||
Generated
+16
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -32,6 +32,7 @@
|
||||
"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",
|
||||
@@ -10114,6 +10115,18 @@
|
||||
"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",
|
||||
@@ -12372,7 +12385,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -105,6 +105,7 @@
|
||||
"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,5 +1,33 @@
|
||||
# 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)
|
||||
terminal.onWriteParsed(() => {
|
||||
if (zerolag.hasPending) zerolag.rerender();
|
||||
});
|
||||
// 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());
|
||||
```
|
||||
|
||||
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 anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,8 +202,9 @@ 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. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
| `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). |
|
||||
|
||||
### Backspace handling
|
||||
|
||||
@@ -217,6 +218,19 @@ 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.
|
||||
@@ -242,7 +256,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. |
|
||||
| `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. |
|
||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||
|
||||
### Prompt
|
||||
@@ -258,7 +272,8 @@ 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 the overlay has any content |
|
||||
| `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) |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||
|
||||
### Options
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"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,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
lines,
|
||||
compositionStart,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
@@ -90,12 +91,24 @@ 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) {
|
||||
visibleLines = lines.slice(lines.length - rows);
|
||||
firstVisible = lines.length - rows;
|
||||
visibleLines = lines.slice(firstVisible);
|
||||
keepsPromptLine = false;
|
||||
topRow = 0;
|
||||
} else if (promptRow + lines.length > rows) {
|
||||
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
topPx,
|
||||
widthPx,
|
||||
cellH,
|
||||
cellW,
|
||||
charTop,
|
||||
charHeight,
|
||||
font,
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
@@ -144,7 +171,10 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -156,7 +186,8 @@ function makeLine(
|
||||
_charTop: number,
|
||||
_charHeight: number,
|
||||
font: FontStyle,
|
||||
terminal?: XtermTerminal | null
|
||||
terminal?: XtermTerminal | null,
|
||||
compositionFrom?: number
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
@@ -172,6 +203,7 @@ 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');
|
||||
@@ -189,9 +221,15 @@ 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,6 +163,12 @@ 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,6 +67,8 @@ 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 = '';
|
||||
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
} else if (this._hasContent()) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
@@ -206,8 +208,14 @@ 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) {
|
||||
@@ -243,6 +251,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -252,6 +263,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._composition = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
@@ -297,7 +309,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText) {
|
||||
if (this._pendingText || this._composition) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
@@ -312,7 +324,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._hasContent()) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
@@ -325,7 +337,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
@@ -391,7 +403,37 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
this._options.prompt = finder;
|
||||
this._lastPromptPos = null;
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
@@ -425,7 +467,13 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return this._pendingText;
|
||||
}
|
||||
|
||||
/** Whether there is any overlay content (pending or flushed). */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
get hasPending(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||
}
|
||||
@@ -443,6 +491,10 @@ 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;
|
||||
@@ -505,7 +557,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
if (!this._hasContent()) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -563,12 +615,16 @@ 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}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -608,6 +664,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
compositionStart: this._composition ? compositionStart : undefined,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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.33.2",
|
||||
"version": "1.34.0",
|
||||
"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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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.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) ----
|
||||
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) ----
|
||||
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,6 +196,11 @@ _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
|
||||
@@ -207,12 +212,19 @@ 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" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} 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
|
||||
@@ -372,10 +384,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.30.1
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
||||
@@ -426,7 +438,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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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'
|
||||
@@ -493,6 +505,12 @@ 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.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.33.4 (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,6 +118,11 @@ _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
|
||||
@@ -129,12 +134,19 @@ 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" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} 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
|
||||
@@ -294,4 +306,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.30.1
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
|
||||
@@ -345,6 +345,13 @@ 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`,
|
||||
@@ -384,17 +391,18 @@ 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`,
|
||||
`envOverrides`). Three differences that break copied code:
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` 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
|
||||
(`session-routes.ts:648`).
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
|
||||
⚠️ `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.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
||||
|
||||
@@ -83,9 +83,11 @@ 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');
|
||||
@@ -111,8 +113,10 @@ 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',
|
||||
|
||||
+26
-8
@@ -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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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.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) ----
|
||||
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) ----
|
||||
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,6 +196,11 @@ _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
|
||||
@@ -207,12 +212,19 @@ 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" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} 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
|
||||
@@ -372,10 +384,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.30.1
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
||||
@@ -426,7 +438,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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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'
|
||||
@@ -493,6 +505,12 @@ 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.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.33.4 (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,6 +118,11 @@ _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
|
||||
@@ -129,12 +134,19 @@ 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" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} 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
|
||||
@@ -294,4 +306,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.30.1
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
|
||||
@@ -345,6 +345,13 @@ 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`,
|
||||
@@ -384,17 +391,18 @@ 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`,
|
||||
`envOverrides`). Three differences that break copied code:
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` 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
|
||||
(`session-routes.ts:648`).
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
|
||||
⚠️ `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.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat } 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
|
||||
@@ -27,6 +28,14 @@ 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,
|
||||
@@ -377,6 +386,26 @@ 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,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { CliEntry } from './types.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
|
||||
|
||||
const HOME_DIRS = {
|
||||
local: '~/.local/bin',
|
||||
@@ -306,6 +307,12 @@ 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.
|
||||
@@ -486,6 +493,12 @@ 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.
|
||||
@@ -532,6 +545,7 @@ 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: [
|
||||
@@ -543,6 +557,14 @@ 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' } },
|
||||
],
|
||||
@@ -620,6 +642,11 @@ 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: {
|
||||
@@ -721,6 +748,12 @@ 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.
|
||||
@@ -800,6 +833,8 @@ 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.
|
||||
|
||||
@@ -90,6 +90,12 @@ 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 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>;
|
||||
/**
|
||||
@@ -511,6 +517,28 @@ 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
|
||||
|
||||
+5
-1
@@ -1031,7 +1031,11 @@ 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.
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
// ⚠️ 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`;
|
||||
await writeFile(tmpPath, desired);
|
||||
await chmod(tmpPath, 0o755);
|
||||
await rename(tmpPath, scriptPath);
|
||||
|
||||
+679
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
@@ -115,6 +115,8 @@ 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 */
|
||||
@@ -164,6 +166,8 @@ 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 { isEffortLevel } from './types.js';
|
||||
import { isAdvisorModel, isEffortLevel } from './types.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
import { compareVersions } from './utils/dependency-checker.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
@@ -54,6 +54,25 @@ 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),
|
||||
@@ -111,6 +130,7 @@ 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(
|
||||
@@ -120,11 +140,21 @@ export function buildInteractiveArgs(
|
||||
allowedTools?: string,
|
||||
effort?: EffortLevel,
|
||||
sessionName?: string,
|
||||
cliVersion?: string | null
|
||||
cliVersion?: string | null,
|
||||
advisorModel?: string
|
||||
): string[] {
|
||||
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
||||
if (model) args.push('--model', model);
|
||||
args.push(...buildEffortCliArgs(effort));
|
||||
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(...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 { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
|
||||
import { buildAdvisorSettings, 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,6 +54,8 @@ 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;
|
||||
/**
|
||||
@@ -198,14 +200,20 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
engineValues.effortLevel = effortValue;
|
||||
}
|
||||
|
||||
// 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) : {};
|
||||
// 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,
|
||||
};
|
||||
if (options.statusLineCommand) {
|
||||
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
||||
}
|
||||
|
||||
+34
-1
@@ -42,6 +42,7 @@ import {
|
||||
NiceConfig,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
getErrorMessage,
|
||||
isAdvisorModel,
|
||||
isEffortLevel,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
@@ -212,6 +213,21 @@ 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;
|
||||
@@ -658,6 +674,11 @@ 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
|
||||
@@ -774,6 +795,8 @@ 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. */
|
||||
@@ -934,6 +957,9 @@ 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;
|
||||
@@ -1827,6 +1853,10 @@ 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,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
@@ -2205,6 +2235,7 @@ 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,
|
||||
@@ -2667,6 +2698,7 @@ 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,
|
||||
@@ -2791,7 +2823,8 @@ export class Session extends EventEmitter {
|
||||
this._allowedTools,
|
||||
this._effort,
|
||||
this.cliPinnedName,
|
||||
getClaudeCliVersion()
|
||||
getClaudeCliVersion(),
|
||||
this._advisorModel
|
||||
);
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
|
||||
@@ -882,6 +882,8 @@ 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`. */
|
||||
@@ -2083,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
@@ -2170,6 +2173,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
sessionName: cliName,
|
||||
});
|
||||
@@ -2397,6 +2401,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
unsetEnvKeys,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
docker,
|
||||
cliName,
|
||||
@@ -2435,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
+3
-1
@@ -24,9 +24,10 @@
|
||||
* | 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 | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.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
|
||||
*
|
||||
@@ -72,3 +73,4 @@ export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
export * from './mcp-sync.js';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @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[];
|
||||
}
|
||||
+43
-2
@@ -6,13 +6,17 @@
|
||||
* 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)
|
||||
*
|
||||
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
|
||||
* `POST /api/push/subscribe`. No dependencies on other domain modules.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** A registered push subscription */
|
||||
@@ -32,3 +36,40 @@ 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;
|
||||
}
|
||||
|
||||
+44
-1
@@ -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, resumeSessionId)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
|
||||
* - 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,6 +377,36 @@ 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") */
|
||||
@@ -398,6 +428,8 @@ 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) */
|
||||
@@ -795,6 +827,17 @@ 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
|
||||
|
||||
+624
-21
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
// Owner tab layout (grouped vertical rail)
|
||||
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
@@ -567,12 +569,26 @@ const DEFAULT_SHORTCUTS = [
|
||||
*/
|
||||
const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||
|
||||
/**
|
||||
* How long a `#session=<id>` link waits for the session list to name its id
|
||||
* before the dashboard drops it with a "Session not found" toast (see
|
||||
* _armUrlSessionWait). Long enough for a page that has just created the
|
||||
* session to see its session:created land here.
|
||||
*/
|
||||
const URL_SESSION_WAIT_MS = 30000;
|
||||
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||
this.draggedTabId = null; // Currently dragged tab session ID
|
||||
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
|
||||
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
|
||||
this.tabLayout = null;
|
||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
this._lastTabGroupStructureKey = null;
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
@@ -602,6 +618,10 @@ class CodemanApp {
|
||||
// service-worker shell loads), with the server-injected global as a fallback.
|
||||
this.soloSessionId = this._detectSoloSessionId();
|
||||
this.isSoloWindow = !!this.soloSessionId;
|
||||
// A session another page asked for with a `#session=<id>` link. It waits
|
||||
// here until the session list has that id (see _selectUrlSession).
|
||||
this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();
|
||||
this._urlSessionWaitTimer = null; // bounds that wait (_armUrlSessionWait)
|
||||
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
||||
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
||||
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
||||
@@ -997,6 +1017,18 @@ class CodemanApp {
|
||||
// strip never flashes before handleInit selects the target session.
|
||||
this._initWindowChannel();
|
||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||
// A page holding this window switches its tab by changing only the
|
||||
// fragment, which keeps the page loaded (see sessionIdFromFragment).
|
||||
if (!this.isSoloWindow) {
|
||||
window.addEventListener('hashchange', () => {
|
||||
const id = this._takeUrlSession();
|
||||
if (!id) return;
|
||||
// A new link replaces one still waiting, and gets a wait of its own.
|
||||
this._retireUrlSession();
|
||||
this._urlSessionId = id;
|
||||
this._selectUrlSession();
|
||||
});
|
||||
}
|
||||
// Initialize mobile handlers
|
||||
KeyboardHandler.init();
|
||||
SwipeHandler.init();
|
||||
@@ -1218,6 +1250,12 @@ class CodemanApp {
|
||||
|
||||
// Use capture to handle before terminal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// A field that exists to show what a key does (Settings → Key tester, `data-raw-keys`) must
|
||||
// receive every chord untouched. Without this, probing Ctrl+W killed the active session,
|
||||
// Ctrl+L cleared the terminal and Escape closed Settings: this listener runs in the capture
|
||||
// phase, before the field's own handler. Must stay the first statement.
|
||||
if (e.target?.closest?.('[data-raw-keys]')) return;
|
||||
|
||||
// Don't intercept keys during CJK IME composition
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
@@ -1368,6 +1406,69 @@ class CodemanApp {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Read a `#session=<id>` link off the URL and drop the fragment. The next
|
||||
* link to the same session is then a change the browser reports, even
|
||||
* after you have clicked away to another tab. Returns the id or null. */
|
||||
_takeUrlSession() {
|
||||
const id = window.CodemanUrlSession?.sessionIdFromFragment(location.hash) ?? null;
|
||||
if (id) {
|
||||
try { history.replaceState(history.state, '', location.pathname + location.search); } catch {}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Show the session a `#session=<id>` link asked for, once the session list
|
||||
* has it. A page that has just created a session can link to it before
|
||||
* session:created arrives here, so an unknown id stays pending (for at most
|
||||
* URL_SESSION_WAIT_MS) and _onSessionCreated tries again.
|
||||
*
|
||||
* ⚠️ The selection is `auto`. The page that set the fragment may be a
|
||||
* script, and this window may not even be in front, so following a link is
|
||||
* not a human looking at the session and must not spend its idle alert. */
|
||||
_selectUrlSession() {
|
||||
const id = this._urlSessionId;
|
||||
if (!id) return false;
|
||||
if (!this.sessions.has(id)) {
|
||||
this._armUrlSessionWait(id);
|
||||
return false;
|
||||
}
|
||||
this._retireUrlSession();
|
||||
this.selectSession(id, { auto: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bound the wait for a link whose id the session list does not have. A
|
||||
* stale link (that session is closed), a typo, or in multi-user mode another
|
||||
* user's session (never in this client's list) would otherwise wait with
|
||||
* nothing on screen, and take the tab whenever a matching session turned up.
|
||||
* One timer per link: handleInit running again (an SSE reconnect) does not
|
||||
* restart it, and every way a link ends goes through _retireUrlSession. */
|
||||
_armUrlSessionWait(id) {
|
||||
if (this._urlSessionWaitTimer) return;
|
||||
this._urlSessionWaitTimer = setTimeout(() => {
|
||||
this._urlSessionWaitTimer = null;
|
||||
if (this._urlSessionId !== id) return;
|
||||
// Listed by a path other than session:created (a session:updated): select it.
|
||||
if (this.sessions.has(id)) {
|
||||
this._selectUrlSession();
|
||||
return;
|
||||
}
|
||||
this._retireUrlSession();
|
||||
this.showToast?.('Session not found', 'warning');
|
||||
}, URL_SESSION_WAIT_MS);
|
||||
}
|
||||
|
||||
/** Drop a waiting `#session=<id>` link and its timer: the link was followed,
|
||||
* replaced by a newer one, timed out, or the user chose something else
|
||||
* (another tab, Home, a web tab). */
|
||||
_retireUrlSession() {
|
||||
this._urlSessionId = null;
|
||||
if (this._urlSessionWaitTimer) {
|
||||
clearTimeout(this._urlSessionWaitTimer);
|
||||
this._urlSessionWaitTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
||||
* point: the tab's pop-out icon calls this, and a future gesture layer
|
||||
@@ -1949,6 +2050,7 @@ class CodemanApp {
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
if (this.sessions.size === 1) this.startSystemStatsPolling();
|
||||
if (this._urlSessionId === data.id) this._selectUrlSession();
|
||||
}
|
||||
|
||||
_onSessionUpdated(data) {
|
||||
@@ -2255,13 +2357,18 @@ class CodemanApp {
|
||||
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
/**
|
||||
* Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable.
|
||||
* `breaks` turns every source newline into a <br>: right for chat, where a
|
||||
* newline is the agent's line break, wrong for a file (the File Viewer passes
|
||||
* false), where a README hard-wrapped at 80 columns would break at every wrap.
|
||||
*/
|
||||
_renderMarkdown(text, { breaks = true } = {}) {
|
||||
const src = text || '';
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
const prepared = this._preprocessAsciiArt(src);
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks, gfm: true }));
|
||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||
// on mobile without collapsing into block-level cells.
|
||||
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
|
||||
@@ -2358,7 +2465,9 @@ class CodemanApp {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const filePath = pathLink.dataset.path;
|
||||
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
|
||||
// A rendered document's links name the session the preview was opened
|
||||
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
|
||||
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4266,6 +4375,10 @@ class CodemanApp {
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
// (Re)read the owner tab layout on every init, including SSE reconnects: a
|
||||
// tab:layoutChanged sent while this client was disconnected is never replayed.
|
||||
this._loadTabLayout();
|
||||
|
||||
if (data.respawnStatus) {
|
||||
this.respawnStatus = data.respawnStatus;
|
||||
} else {
|
||||
@@ -4367,6 +4480,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// A `#session=<id>` link wins over restoring the last active tab.
|
||||
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
|
||||
this.activeSessionId = null;
|
||||
this._selectUrlSession();
|
||||
return;
|
||||
}
|
||||
// Not listed yet: its wait starts now that the list has loaded, and the
|
||||
// last active tab is restored meanwhile.
|
||||
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
|
||||
|
||||
const previousActiveId = this.activeSessionId;
|
||||
if (this.sessionOrder.length === 0) {
|
||||
this.activeSessionId = null;
|
||||
@@ -5078,6 +5201,12 @@ class CodemanApp {
|
||||
_updateActiveTabImmediate(sessionId) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
// Grouped rail: selecting a session hidden in a collapsed group must show it
|
||||
// (and re-hide the previous exception), which a class toggle cannot do.
|
||||
if (this._isTabGroupStructureStale()) {
|
||||
this._fullRenderSessionTabs();
|
||||
return;
|
||||
}
|
||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.id === sessionId) {
|
||||
@@ -5086,6 +5215,7 @@ class CodemanApp {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
this._syncTabTreeSelection(container);
|
||||
// #257: selection used to stop at the class toggle. On phones/tablets the
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
@@ -5237,7 +5367,12 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
|
||||
// against the rows the projection SHOWS, not every live session.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const currentIds = groupProjection
|
||||
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
|
||||
: new Set(this.sessions.keys());
|
||||
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
@@ -5246,14 +5381,20 @@ class CodemanApp {
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const wantedWebIds = groupProjection
|
||||
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
|
||||
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
// The grouped rail's structure (sections, collapse, the shown exception) can
|
||||
// change while the id sets stay equal; the in-place patch below cannot move
|
||||
// or hide a row, so any structural change takes the full rebuild.
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
webTabsUnchanged &&
|
||||
!this._isTabGroupStructureStale(groupProjection);
|
||||
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
@@ -5440,6 +5581,15 @@ class CodemanApp {
|
||||
subagentBadgeEl.remove();
|
||||
}
|
||||
}
|
||||
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
|
||||
// highlight, and change the alerts a collapsed header stands in for, none
|
||||
// of which rebuilds the rail. Keep what assistive tech and the headers
|
||||
// report in step. The flat list (role=tablist) never takes this branch.
|
||||
if (groupProjection && container.getAttribute('role') === 'tree') {
|
||||
this._syncTabTreeSelection(container);
|
||||
this._applyTabTreePositions(container);
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5571,6 +5721,12 @@ class CodemanApp {
|
||||
const prevScrollTop = container.scrollTop;
|
||||
const prevActiveTabId = this._lastRenderedActiveTabId;
|
||||
const isFirstRender = !container.querySelector('.session-tab');
|
||||
// The rebuild below destroys the focused row. In the grouped tree, put focus
|
||||
// back on the same item (by identity) so a background render or a keyboard
|
||||
// collapse does not drop a keyboard user to <body>.
|
||||
const focusWasInside = container.contains(document.activeElement);
|
||||
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
|
||||
this._tabFocusIdentity = null;
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance.
|
||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||
@@ -5591,6 +5747,10 @@ class CodemanApp {
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// grouped rail places the SAME markup into its sections, so a row never
|
||||
// differs between the two (badge = Alt+N slot in sessionOrder either way).
|
||||
const rowHtml = new Map();
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
@@ -5654,7 +5814,7 @@ class CodemanApp {
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5677,12 +5837,41 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
const groupProjection = this._projectTabGroups();
|
||||
if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
const webviewSlots = new Map(
|
||||
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
|
||||
);
|
||||
parts.push(
|
||||
window.CodemanTabLayout.renderProjection(
|
||||
groupProjection,
|
||||
(ref) =>
|
||||
ref.kind === 'session'
|
||||
? rowHtml.get(ref.id) || ''
|
||||
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
|
||||
escapeHtml
|
||||
)
|
||||
);
|
||||
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
|
||||
} else {
|
||||
parts.push(...rowHtml.values());
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
}
|
||||
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
this._applyTabListRole(container, !!groupProjection);
|
||||
if (groupProjection) {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -5733,6 +5922,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this._tabKeydownHandler = (e) => {
|
||||
// The grouped rail is a tree with its own key model; everything else
|
||||
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
|
||||
if (container.getAttribute('role') === 'tree') {
|
||||
this._handleTabTreeKeydown(e, container);
|
||||
return;
|
||||
}
|
||||
// Up/Down are aliases of Left/Right, not replacements: the strip stays
|
||||
// arrow-key navigable exactly as before, the vertical sidebar just gains
|
||||
// the axis a user reaches for there.
|
||||
@@ -5757,8 +5952,7 @@ class CodemanApp {
|
||||
// Enter or Space activates the tab
|
||||
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const sessionId = tabs[currentIndex].dataset.id;
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
this._activateTabRow(tabs[currentIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5789,6 +5983,250 @@ class CodemanApp {
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', this._tabKeydownHandler);
|
||||
|
||||
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
|
||||
// Tab-ing away and back returns to it and there is still exactly one stop.
|
||||
if (!this._tabTreeFocusinHandler) {
|
||||
this._tabTreeFocusinHandler = (e) => {
|
||||
if (container.getAttribute('role') !== 'tree') return;
|
||||
const item = e.target?.closest?.('[role="treeitem"]');
|
||||
if (item && container.contains(item)) this._setTabTreeStop(container, item);
|
||||
};
|
||||
container.addEventListener('focusin', this._tabTreeFocusinHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
|
||||
_activateTabRow(row) {
|
||||
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
|
||||
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Grouped rail: tree semantics and keyboard model
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
|
||||
// named-group headers are level-1 treeitems that own their rows (level 2);
|
||||
// ungrouped rows and a collapsed group's kept selection are level 1. One item
|
||||
// carries tabindex=0 (roving), every control inside a row is removed from the
|
||||
// tab order, and focus is restored by identity across full re-renders. The
|
||||
// flat rail and the header strip keep role=tablist / role=tab untouched.
|
||||
|
||||
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
|
||||
_applyTabListRole(container, grouped) {
|
||||
if (grouped) {
|
||||
container.setAttribute('role', 'tree');
|
||||
container.setAttribute('aria-label', 'Sessions');
|
||||
} else if (container.getAttribute('role') === 'tree') {
|
||||
container.setAttribute('role', 'tablist');
|
||||
container.setAttribute('aria-label', 'Session tabs');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identity of a tree item (or anything inside one) across re-renders. */
|
||||
_tabTreeIdentity(element) {
|
||||
const item = element?.closest?.('[data-tab-group-header], .session-tab');
|
||||
if (!item) return null;
|
||||
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
|
||||
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
|
||||
if (item.dataset.id) return `session:${item.dataset.id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible tree items in the order the eye reads them: each section's header,
|
||||
* then its rows. A sorted rail paints rows with the flex `order` property
|
||||
* inside their own group column, so rows are ordered by COMPUTED order within
|
||||
* a section (stable, so the unsorted rail keeps DOM order).
|
||||
*/
|
||||
_tabTreeItems(container) {
|
||||
const sorted = this.isTabRailSorted();
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
const items = [];
|
||||
for (const section of container.querySelectorAll('.tab-layout-group')) {
|
||||
const header = section.querySelector(':scope > [role="treeitem"]');
|
||||
if (header) items.push(header);
|
||||
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
|
||||
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
items.push(...rows);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Move the single tab stop to `item` (every other tree item gets -1). */
|
||||
_setTabTreeStop(container, item) {
|
||||
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn freshly rendered rows into tree items and place the roving tab stop.
|
||||
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
|
||||
* its controls); only their semantics change here, never their content.
|
||||
*/
|
||||
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
|
||||
for (const row of container.querySelectorAll('.session-tab')) {
|
||||
row.setAttribute('role', 'treeitem');
|
||||
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
|
||||
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
|
||||
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
|
||||
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
|
||||
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
|
||||
control.tabIndex = -1;
|
||||
}
|
||||
}
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
|
||||
const items = this._applyTabTreePositions(container);
|
||||
|
||||
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
|
||||
// A focused row that a collapse just hid hands focus to its group header.
|
||||
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
|
||||
const target =
|
||||
byIdentity(identity) ||
|
||||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
|
||||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
|
||||
items[0];
|
||||
if (!target) return;
|
||||
this._setTabTreeStop(container, target);
|
||||
if (refocus && document.activeElement !== target) target.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* aria-posinset / aria-setsize within each level, in PAINTED order: the
|
||||
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
|
||||
* group's own rows. Runs after every full render AND after an incremental pass,
|
||||
* because the activity-sorted rail re-sorts rows in place. Returns the items.
|
||||
*/
|
||||
_applyTabTreePositions(container) {
|
||||
const items = this._tabTreeItems(container);
|
||||
const sets = new Map();
|
||||
for (const item of items) {
|
||||
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
|
||||
if (!sets.has(owner)) sets.set(owner, []);
|
||||
sets.get(owner).push(item);
|
||||
}
|
||||
for (const members of sets.values()) {
|
||||
members.forEach((item, index) => {
|
||||
const setsize = String(members.length);
|
||||
const posinset = String(index + 1);
|
||||
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
|
||||
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed group hides its rows, including ones that need the user. Its
|
||||
* header takes the most urgent hidden alert in the tab alert language
|
||||
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
|
||||
* behind a collapse is never invisible. Patched in place on both render paths:
|
||||
* alerts change without a rebuild.
|
||||
*/
|
||||
_syncTabGroupHeaderAlerts(container, projection) {
|
||||
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
|
||||
const alert = alerts[header.dataset.tabGroupHeader];
|
||||
header.classList.toggle('tab-alert-action', alert === 'action');
|
||||
header.classList.toggle('tab-alert-idle', alert === 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
||||
_syncTabTreeSelection(container) {
|
||||
if (container?.getAttribute('role') !== 'tree') return;
|
||||
for (const row of container.querySelectorAll('.session-tab')) {
|
||||
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
|
||||
* visible items, Home/End jump, Right expands a header or enters it, Left
|
||||
* collapses a header or climbs from a row to its header, Enter/Space select a
|
||||
* row or toggle a header, Shift+F10 / ContextMenu open a row's actions.
|
||||
*/
|
||||
_handleTabTreeKeydown(e, container) {
|
||||
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
const items = this._tabTreeItems(container);
|
||||
const current = e.target?.closest?.('[role="treeitem"]');
|
||||
// A key pressed on a control INSIDE a row (its close or overflow button,
|
||||
// focused by a click or handed focus back by the action menu) belongs to
|
||||
// that control: Enter there must reopen the menu, not re-select the row.
|
||||
// Same contract as the flat list, which acts only on a focused row.
|
||||
if (!current || current !== e.target) return;
|
||||
const index = items.indexOf(current);
|
||||
if (index < 0) return;
|
||||
const groupId = current.dataset.tabGroupHeader || null;
|
||||
const expanded = current.getAttribute('aria-expanded') === 'true';
|
||||
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
|
||||
const expandable = current.hasAttribute('aria-expanded');
|
||||
const focusAt = (next) => {
|
||||
if (!next) return;
|
||||
this._setTabTreeStop(container, next);
|
||||
next.focus();
|
||||
};
|
||||
const toggle = (collapse) => {
|
||||
// The toggle re-renders the rail; keep focus on this header through it.
|
||||
this._tabFocusIdentity = `group:${groupId}`;
|
||||
this.toggleTabGroupCollapsed(groupId, collapse);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp': {
|
||||
const step = e.key === 'ArrowDown' ? 1 : -1;
|
||||
focusAt(items[(index + step + items.length) % items.length]);
|
||||
break;
|
||||
}
|
||||
case 'Home':
|
||||
focusAt(items[0]);
|
||||
break;
|
||||
case 'End':
|
||||
focusAt(items[items.length - 1]);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (!groupId || !expandable) return;
|
||||
if (!expanded) toggle(false);
|
||||
else {
|
||||
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
|
||||
if (!child) return;
|
||||
focusAt(child);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (groupId) {
|
||||
if (!expandable || !expanded) return;
|
||||
toggle(true);
|
||||
} else {
|
||||
const group = current.closest('[role="group"]');
|
||||
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
|
||||
if (!header) return;
|
||||
focusAt(header);
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (groupId) toggle();
|
||||
else this._activateTabRow(current);
|
||||
break;
|
||||
case 'F10':
|
||||
case 'ContextMenu':
|
||||
if (e.key === 'F10' && !e.shiftKey) return;
|
||||
if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(
|
||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
|
||||
current.dataset.id
|
||||
);
|
||||
} else if (current.dataset.webviewId) {
|
||||
this.showWebviewModal?.(current.dataset.webviewId);
|
||||
} else return;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleSessionTabClick(event, sessionId) {
|
||||
@@ -5873,6 +6311,141 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: grouped vertical rail (read-only)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
|
||||
// already projects them onto the global session order, so sessionOrder, Alt+N,
|
||||
// Ctrl+Tab and every other order consumer are untouched here. This layer only
|
||||
// decides how the VERTICAL rail draws rows: in sections, with per-device
|
||||
// collapse. With no groups (or any read failure) the rail is the flat list it
|
||||
// has always been.
|
||||
|
||||
_ensureTabLayoutCoordinator() {
|
||||
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
||||
if (!window.CodemanTabLayout) return null;
|
||||
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
const data = await this._apiJson('/api/tab-layout');
|
||||
if (!data?.layout) throw new Error('Tab layout unavailable');
|
||||
return data.layout;
|
||||
},
|
||||
applyLayout: (layout) => this._applyTabLayout(layout),
|
||||
applyFallback: () => this._applyTabLayout(null),
|
||||
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
|
||||
// tab:layoutChanged asks again: an unreachable server must not cost a
|
||||
// fetch every 5 s for as long as the page stays open.
|
||||
retryDelayMs: 5000,
|
||||
maxRetryDelayMs: 60000,
|
||||
maxRetries: 4,
|
||||
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
|
||||
cancelRetry: (timer) => clearTimeout(timer),
|
||||
});
|
||||
return this._tabLayoutCoordinator;
|
||||
}
|
||||
|
||||
_loadTabLayout() {
|
||||
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
|
||||
}
|
||||
|
||||
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
|
||||
_onTabLayoutChanged(data) {
|
||||
const me = window.__codemanUser;
|
||||
// Another user's layout changed: nothing of ours moved. (The GET is
|
||||
// owner-scoped server-side, so this is a saved request, not a guard.)
|
||||
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
|
||||
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
|
||||
this._loadTabLayout();
|
||||
}
|
||||
|
||||
/** Adopt a layout read (or null after a failed read, which renders flat). */
|
||||
_applyTabLayout(layout) {
|
||||
let next = null;
|
||||
if (layout) {
|
||||
try {
|
||||
next = window.CodemanTabLayout.normalizeLayout(layout);
|
||||
} catch {
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
// An overtaken response is already dropped by the coordinator; this guards a
|
||||
// reordering between the coordinator and an SSE-triggered reload.
|
||||
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
|
||||
this.tabLayout = next;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
|
||||
: { ids: [], ok: !next };
|
||||
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(collapsed.ids);
|
||||
// The server announces a layout change on every session create/close, web
|
||||
// tab create/delete and order PUT, and most of those move nothing on this
|
||||
// rail (always so on the flat rail, which is every owner without groups).
|
||||
// Rebuild only when what the rail would draw actually changed.
|
||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
||||
_getTabCollapseStorage() {
|
||||
if (this._tabCollapseStorageFailed) return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
this._tabCollapseStorageFailed = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouped projection for the CURRENT render, or null when the rail should
|
||||
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
|
||||
* no layout yet, a failed read, or a layout without groups.
|
||||
*/
|
||||
_projectTabGroups() {
|
||||
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
|
||||
return window.CodemanTabLayout.project(this.tabLayout, {
|
||||
// sessionOrder, not the sessions Map: rows are built in that order, so a
|
||||
// session the layout has not placed yet lands where the flat strip has it.
|
||||
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
|
||||
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
|
||||
collapsedGroupIds: [...this.collapsedTabGroupIds],
|
||||
activeSessionId: this.activeSessionId,
|
||||
activeWebviewId: this.activeWebviewId,
|
||||
});
|
||||
}
|
||||
|
||||
_tabGroupStructureKey(projection) {
|
||||
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
|
||||
}
|
||||
|
||||
/** True when the DOM's grouping no longer matches what a render would draw. */
|
||||
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
|
||||
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse/expand one group (header click). Per-device: stored in localStorage,
|
||||
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
|
||||
* A storage failure leaves every group expanded rather than half-remembered.
|
||||
*/
|
||||
toggleTabGroupCollapsed(groupId, forceCollapsed) {
|
||||
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
|
||||
const next = new Set(this.collapsedTabGroupIds);
|
||||
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
|
||||
if (shouldCollapse) next.add(groupId);
|
||||
else next.delete(groupId);
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const saved = storage
|
||||
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
|
||||
: { ids: [], ok: false };
|
||||
if (!saved.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(saved.ids);
|
||||
// The full render also redraws connectors anchored to rows that just moved.
|
||||
this._fullRenderSessionTabs();
|
||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -5884,7 +6457,10 @@ class CodemanApp {
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
if (this.isTabRailSorted()) {
|
||||
// The grouped rail is read-only for now: a flat-order drag cannot express
|
||||
// "move into that group", and the server would re-rank it within its old
|
||||
// group anyway. Grouped editing comes with its own drag model.
|
||||
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
}
|
||||
@@ -5975,10 +6551,26 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
|
||||
* neighbour in its OWN section. Across a group boundary the server re-ranks
|
||||
* each group on its own (`putLegacyOrder`), so nothing moves there, no
|
||||
* session:orderChanged comes back, and this client would keep a swapped
|
||||
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
|
||||
* drag is off in the grouped rail. Any other layout: always allowed.
|
||||
*/
|
||||
_canSwapActiveTabWith(neighbourId) {
|
||||
const projection = this._projectTabGroups();
|
||||
if (!projection) return true;
|
||||
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
|
||||
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
|
||||
}
|
||||
|
||||
moveActiveTabLeft() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx <= 0) return;
|
||||
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return;
|
||||
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5988,6 +6580,7 @@ class CodemanApp {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
|
||||
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -6560,6 +7153,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async selectSession(sessionId, options = {}) {
|
||||
// Picking another tab yourself retires a `#session=<id>` link still
|
||||
// waiting for its session, which would otherwise take the tab from you
|
||||
// whenever that session turned up (see _selectUrlSession).
|
||||
if (options?.auto !== true && this._urlSessionId && this._urlSessionId !== sessionId) {
|
||||
this._retireUrlSession();
|
||||
}
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
||||
@@ -6569,12 +7168,13 @@ class CodemanApp {
|
||||
}
|
||||
const forceReload = options?.forceReload === true;
|
||||
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
|
||||
// the boot restore, a solo window opening its target, the fallback after
|
||||
// the active session is deleted. Those must NOT spend a pending idle alert
|
||||
// (the yellow survives until a real tap), because "the app put this on
|
||||
// screen" is not "I checked it". The DEFAULT is user-initiated, so a call
|
||||
// site nobody tagged fails toward acknowledging rather than toward an
|
||||
// alert that can never be cleared.
|
||||
// the boot restore, a solo window opening its target, a `#session=<id>`
|
||||
// link from another page, the fallback after the active session is
|
||||
// deleted. Those must NOT spend a pending idle alert (the yellow survives
|
||||
// until a real tap), because "the app put this on screen" is not "I
|
||||
// checked it". The DEFAULT is user-initiated, so a call site nobody tagged
|
||||
// fails toward acknowledging rather than toward an alert that can never be
|
||||
// cleared.
|
||||
const userInitiated = options?.auto !== true;
|
||||
if (this.activeSessionId === sessionId && !forceReload) {
|
||||
// Tapping the tab you are already on is still "I checked it". The alert
|
||||
@@ -7537,6 +8137,9 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
goHome() {
|
||||
// Going Home is choosing something else, so a `#session=<id>` link still
|
||||
// waiting for its session must not take the screen later.
|
||||
this._retireUrlSession();
|
||||
// Deselect active session and show welcome screen
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
|
||||
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
|
||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||
*/
|
||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
);
|
||||
|
||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||
@@ -1853,10 +1853,27 @@ function reconcilePtyGeometry(local, pty) {
|
||||
return { adopt: true, cols: pty.cols };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which session does a dashboard URL's fragment ask for? Another page that
|
||||
* holds the dashboard's window, such as a task board, points it at
|
||||
* `/#session=<id>`. Only the fragment changes between two such links, so the
|
||||
* browser keeps the page loaded and fires `hashchange`, and the dashboard
|
||||
* switches tabs without reloading. Any other fragment asks for nothing.
|
||||
*
|
||||
* @param {string} hash - `location.hash`, with or without its leading `#`
|
||||
* @returns {string|null} the session id, or null
|
||||
*/
|
||||
function sessionIdFromFragment(hash) {
|
||||
const params = new URLSearchParams(String(hash || '').replace(/^#/, ''));
|
||||
const id = params.get('session');
|
||||
return id && id.trim() ? id.trim() : null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanUrlSession = { sessionIdFromFragment };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
'Open away digest': '打开离开期间摘要',
|
||||
'Session Manager': '会话管理器',
|
||||
'Session actions': '会话操作',
|
||||
Ungrouped: '未分组',
|
||||
'Open session manager': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
@@ -571,6 +572,8 @@
|
||||
'Task Complete': '任务完成',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||
'Session not found': '未找到会话',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
@@ -727,6 +730,9 @@
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Rendered markdown': '渲染 Markdown',
|
||||
'Line numbers': '行号',
|
||||
'Wrap lines': '自动换行',
|
||||
'Unsaved changes': '未保存的更改',
|
||||
Saved: '已保存',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
|
||||
@@ -573,6 +573,9 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||
@@ -1819,6 +1822,22 @@
|
||||
</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 ═══════════════════════════════════════ -->
|
||||
@@ -2169,6 +2188,19 @@
|
||||
<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>
|
||||
|
||||
@@ -2471,6 +2503,30 @@
|
||||
</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 ════════════════════════════════════════════ -->
|
||||
@@ -2598,6 +2654,57 @@
|
||||
</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 ════════════════════════════════════════════════════ -->
|
||||
@@ -3735,10 +3842,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>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @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);
|
||||
+320
-10
@@ -20,6 +20,20 @@ const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
|
||||
// a huge log is a partial read rather than a download the viewer throws away.
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
||||
const TEXT_PREVIEW_MAX_LINES = 500;
|
||||
// A markdown document gets the route's ceiling instead of the 500-line preview
|
||||
// cap: a rendered README cut mid-way reads as the whole document.
|
||||
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
|
||||
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
// File Viewer text-view prefs: per-device, in their own localStorage keys for
|
||||
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
|
||||
// rebuilt from the settings modal on save, so a key toggled from the viewer
|
||||
// would be dropped on the next save).
|
||||
const FILE_PREVIEW_PREF_KEYS = {
|
||||
mdRendered: 'codeman:filePreviewMdRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -339,6 +353,67 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Save what had focus before a search-first overlay takes it.
|
||||
*
|
||||
* The Command Palette and the Session Manager both call `search.focus()` on
|
||||
* open, and both used to close by removing the `active` class and nothing
|
||||
* else. Hiding the focused input does not hand focus back to anyone — the
|
||||
* browser drops it on `<body>` — so after Escape closed the overlay every
|
||||
* keystroke went nowhere and the user had to click the terminal to type
|
||||
* again (measured: `document.activeElement` is BODY afterwards and the
|
||||
* terminal emits no onData at all). Every close path has the same hole, so
|
||||
* the restore lives in the close functions, not in the global Escape chain.
|
||||
*
|
||||
* ⚠️ Deliberately only the save/restore half of {@link FocusTrap}, not the
|
||||
* whole thing. `FocusTrap.activate()` moves focus to the first focusable
|
||||
* element, which in both of these overlays is not the search box — adopting
|
||||
* it wholesale would fix the focus loss by breaking the thing Cmd+K exists
|
||||
* for, typing a filter the moment it opens.
|
||||
*/
|
||||
_rememberOverlayFocus(key) {
|
||||
this[key] = (typeof document !== 'undefined' && document.activeElement) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hand focus back to whatever {@link _rememberOverlayFocus} saved.
|
||||
*
|
||||
* ⚠️ The terminal fallback is gated on there being an active session: an
|
||||
* overlay opened from the welcome screen has no terminal to return to, and
|
||||
* focusing one on a phone summons the on-screen keyboard over a screen that
|
||||
* has no input on it.
|
||||
*
|
||||
* ⚠️ A focus that already left the overlay is kept, not overridden. The
|
||||
* Session Manager's row menu ("Switch to session", "Open folder") calls
|
||||
* selectSession(), which focuses the terminal, BEFORE closeSessionManager();
|
||||
* restoring there would pull focus back to the header button that opened the
|
||||
* modal. Only a focus still inside `modal`, or one dropped on `<body>`, is
|
||||
* the overlay's to hand back.
|
||||
*/
|
||||
_restoreOverlayFocus(key, modal) {
|
||||
const prev = this[key];
|
||||
this[key] = null;
|
||||
const body = typeof document !== 'undefined' ? document.body : null;
|
||||
const current = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (current && current !== body && modal?.contains?.(current) === false) return;
|
||||
// `isConnected === false` means the element was removed while the overlay
|
||||
// was open (a re-render of the tab strip, say); anything else — including
|
||||
// a stub with no such property — is treated as still focusable.
|
||||
if (prev && prev !== body && prev.isConnected !== false && typeof prev.focus === 'function') {
|
||||
prev.focus();
|
||||
return;
|
||||
}
|
||||
// ⚠️ `activeSessionId` alone only covers the welcome screen. On a touch device
|
||||
// with the keyboard down, focus sits on `<body>`, so focusing the terminal here
|
||||
// would summon the on-screen keyboard — `selectSession()` deliberately skips the
|
||||
// focus for exactly that reason, and this would override it. The app's own
|
||||
// predicate already encodes the rule (true on desktop, on touch only while the
|
||||
// keyboard is open); the optional call keeps the vm test harness working.
|
||||
if (this.activeSessionId && this._shouldFocusTerminalForTabSwitch?.() !== false) {
|
||||
this.terminal?.focus?.();
|
||||
}
|
||||
},
|
||||
|
||||
openCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
@@ -351,13 +426,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._wireCommandPalette();
|
||||
this.renderCommandPalette();
|
||||
|
||||
// BEFORE the steal, not after: `search.focus()` below is what loses the
|
||||
// caller's focus, so the read has to happen while it is still there.
|
||||
this._rememberOverlayFocus('_commandPalettePrevFocus');
|
||||
search.focus();
|
||||
search.select?.();
|
||||
},
|
||||
|
||||
closeCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
// ⚠️ Bail out when it was not open. The global Escape handler calls this on
|
||||
// EVERY Escape (app.js), in the CAPTURE phase, so an unconditional restore
|
||||
// runs before the focused element's own Escape handler and steals focus into
|
||||
// the terminal: keys typed after Escape in split Pane B land in Pane A, keys
|
||||
// typed in any text field land in the terminal, and the inline tab rename's
|
||||
// Escape fires the input's blur (which commits) before its own handler
|
||||
// (which cancels), turning a cancel into a rename.
|
||||
if (!modal?.classList?.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._restoreOverlayFocus('_commandPalettePrevFocus', modal);
|
||||
},
|
||||
|
||||
_wireCommandPalette() {
|
||||
@@ -618,6 +705,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
}
|
||||
search.value = '';
|
||||
this._rememberOverlayFocus('_sessionManagerPrevFocus');
|
||||
search.focus();
|
||||
}
|
||||
await this._loadSessionManagerList('');
|
||||
@@ -625,7 +713,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
closeSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
// Same guard as closeCommandPalette — see the note there.
|
||||
if (!modal?.classList?.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._restoreOverlayFocus('_sessionManagerPrevFocus', modal);
|
||||
},
|
||||
|
||||
/** Replace the Session Manager list body with a single status line. */
|
||||
@@ -4012,6 +4103,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
// Same for the text-view toggles: they act on the text this load has not
|
||||
// fetched yet, and an image or PDF has nothing for them to toggle.
|
||||
this.filePreviewText = null;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
@@ -4095,12 +4190,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const text = await res.text();
|
||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
|
||||
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
|
||||
// Markdown keeps every line the Range read returned: the byte bound is
|
||||
// what protects the tab, and a rendered document cut at 500 lines
|
||||
// reads as the whole document.
|
||||
const lineCap = MARKDOWN_EXTS.has(ext) ? Infinity : TEXT_PREVIEW_MAX_LINES;
|
||||
const clippedByLines = lines.length > lineCap;
|
||||
const shown = clippedByLines ? lines.slice(0, lineCap).join('\n') : text;
|
||||
this.filePreviewContent = shown;
|
||||
// attachmentId: a card's filePath is the bare file name, so the
|
||||
// rebase pass must know there is no directory to resolve against.
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
|
||||
this._renderFilePreviewText();
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
|
||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -4146,8 +4248,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// 500 lines is what keeps a huge log from locking the tab in one <pre>;
|
||||
// markdown is rendered as a document and takes the route's ceiling instead.
|
||||
const lineCap = MARKDOWN_EXTS.has(ext) ? MARKDOWN_PREVIEW_MAX_LINES : TEXT_PREVIEW_MAX_LINES;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
const res = await fetch(
|
||||
`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=${lineCap}`
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
@@ -4172,10 +4279,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
// Text content
|
||||
// Text content: rendered markdown or plain text, per the viewer's toggles.
|
||||
this.filePreviewContent = data.content;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||
this.filePreviewText = { ext, sessionId, filePath };
|
||||
this._renderFilePreviewText();
|
||||
const truncNote = data.truncated ? ` (showing ${lineCap}/${data.totalLines} lines)` : '';
|
||||
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||
// Edit affordance only when the server says an edit=1 re-fetch would
|
||||
// succeed (workspace text file inside the allowlist and size cap).
|
||||
@@ -4203,6 +4311,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// audible and keeps streaming from the server. Closing has to stop it.
|
||||
this._stopFilePreviewMedia();
|
||||
this.filePreviewContent = '';
|
||||
this.filePreviewText = null;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
@@ -4251,6 +4361,204 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer text view: rendered markdown, line numbers, wrap
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_filePreviewPref(name) {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILE_PREVIEW_PREF_KEYS[name]);
|
||||
if (stored === '1') return true;
|
||||
if (stored === '0') return false;
|
||||
} catch {
|
||||
/* private mode: fall through to the default */
|
||||
}
|
||||
return FILE_PREVIEW_PREF_DEFAULTS[name];
|
||||
},
|
||||
|
||||
_setFilePreviewPref(name, on) {
|
||||
try {
|
||||
localStorage.setItem(FILE_PREVIEW_PREF_KEYS[name], on ? '1' : '0');
|
||||
} catch {
|
||||
/* private mode: the toggle still applies for this page load */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Paint the loaded text (filePreviewContent) into the preview body: a
|
||||
* rendered document for .md/.markdown while the MD toggle is on, otherwise
|
||||
* plain text with one span per line so the Lines toggle can number them.
|
||||
* The MD toggle re-runs this without a refetch.
|
||||
*
|
||||
* Markdown goes through the same pipeline as the Response Viewer
|
||||
* (`_renderMarkdown`: marked + the DOMPurify allowlist), never a second
|
||||
* parser, and is built inside a <template>: a detached div with innerHTML
|
||||
* already set starts fetching every <img src>, so the document's relative
|
||||
* image paths would hit the server as /docs/img.png 404s before
|
||||
* `_rebaseFilePreviewMarkdownRefs` rewrote them. `breaks: false` because a
|
||||
* file is not a chat message: a paragraph hard-wrapped in the source is one
|
||||
* paragraph, as on GitHub.
|
||||
*/
|
||||
_renderFilePreviewText() {
|
||||
const info = this.filePreviewText;
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
if (!info || !bodyEl) return;
|
||||
const isMarkdown = MARKDOWN_EXTS.has(info.ext);
|
||||
const rendered = isMarkdown && this._filePreviewPref('mdRendered');
|
||||
if (rendered) {
|
||||
// data-i18n-skip: the translator's MutationObserver would otherwise
|
||||
// rewrite the document's own headings and paragraphs.
|
||||
const tmpl = document.createElement('template');
|
||||
tmpl.innerHTML = `<div class="rv-text file-preview-md" data-i18n-skip>${this._renderMarkdown(this.filePreviewContent, { breaks: false })}</div>`;
|
||||
const doc = tmpl.content.firstElementChild;
|
||||
this._rebaseFilePreviewMarkdownRefs(doc, info);
|
||||
this._linkifyFilePaths(doc);
|
||||
// The linkifier's absolute paths name no session; give them the
|
||||
// preview's, like the rebased links, or they open in the active tab's.
|
||||
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
|
||||
bodyEl.replaceChildren(tmpl.content);
|
||||
// The Response Viewer's click delegate (path links, code-block copy
|
||||
// buttons, loopback links): container-bound and idempotent, so binding it
|
||||
// on the body once serves every preview.
|
||||
this._bindResponseViewerInteractions(bodyEl);
|
||||
} else {
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'file-preview-text';
|
||||
pre.classList.toggle('wrap', this._filePreviewPref('wrap'));
|
||||
pre.classList.toggle('show-lines', this._filePreviewPref('lineNumbers'));
|
||||
const code = document.createElement('code');
|
||||
// One span per line joined by real newlines: empty lines survive, select
|
||||
// and copy return the exact text, and the gutter counter hangs off the
|
||||
// spans' ::before so the numbers are never part of the text.
|
||||
code.innerHTML = this.filePreviewContent
|
||||
.split('\n')
|
||||
.map((line) => `<span class="fp-line">${escapeHtml(line)}</span>`)
|
||||
.join('\n');
|
||||
pre.appendChild(code);
|
||||
bodyEl.replaceChildren(pre);
|
||||
}
|
||||
this._updateFilePreviewToolbar(rendered ? 'markdown' : 'text');
|
||||
},
|
||||
|
||||
/**
|
||||
* Point a rendered document's workspace references at the file it came from.
|
||||
*
|
||||
* Images are rebased onto the workspace-confined file-raw route under the
|
||||
* document's directory, root-relative ones (`/docs/x.png`) under the
|
||||
* workspace root as on GitHub (the server refuses escapes, so `..` is safe
|
||||
* to forward). Whatever fails to load degrades to its alt text with one
|
||||
* error handler: a remote image the page CSP blocks, a 404 for a document
|
||||
* outside the workspace, an SVG that file-raw serves as a download. Links
|
||||
* take the `a.rv-path` shape the Response Viewer delegate already opens in
|
||||
* this overlay, minus the target/rel `_renderMarkdown` gave them, which
|
||||
* would otherwise open <origin>/docs/x.md in a new tab, and carry the
|
||||
* preview's own session so a document opened from another session's
|
||||
* attachment card resolves against that workspace, not the active tab's.
|
||||
*
|
||||
* A preview opened by attachment id under a bare file name (attachment
|
||||
* cards and the history drawer: the registry keeps no relative path) has no
|
||||
* directory to resolve against, and the workspace root is the wrong one for
|
||||
* docs/report.md and for a file outside the workspace alike. Its workspace
|
||||
* refs degrade instead: images to their alt text, links to their text,
|
||||
* rather than a missing image or a silently different file.
|
||||
*/
|
||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
||||
const unresolvable = !!attachmentId && !filePath.startsWith('/');
|
||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
||||
const isWorkspaceRef = (ref) =>
|
||||
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
|
||||
// GitHub-style `img.png#gh-dark-mode-only`, `doc.md#section` and
|
||||
// `img.png?raw=true`: neither fragment nor query is part of the path.
|
||||
// marked percent-encodes destinations (`my image.png` arrives as
|
||||
// `my%20image.png`), so decode before the route encodes again, or file-raw
|
||||
// looks for a file literally named `my%20image.png`; a malformed escape
|
||||
// keeps the ref as written. `.` and `..` segments are collapsed so the
|
||||
// title reads `README.md`, not `docs/../README.md`; a `..` that climbs
|
||||
// past the start is kept and left for the server to refuse.
|
||||
const resolveRef = (ref) => {
|
||||
let rel = ref.split('#')[0].split('?')[0];
|
||||
try {
|
||||
rel = decodeURIComponent(rel);
|
||||
} catch {
|
||||
/* malformed escape: keep the ref as written */
|
||||
}
|
||||
const parts = [];
|
||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
|
||||
if (seg === '.' || (seg === '' && parts.length)) continue;
|
||||
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
|
||||
else parts.push(seg);
|
||||
}
|
||||
return parts.join('/');
|
||||
};
|
||||
for (const img of root.querySelectorAll('img[src]')) {
|
||||
const src = img.getAttribute('src') || '';
|
||||
if (unresolvable && isWorkspaceRef(src)) {
|
||||
img.replaceWith(img.getAttribute('alt') || src);
|
||||
continue;
|
||||
}
|
||||
if (isWorkspaceRef(src)) {
|
||||
const path = resolveRef(src);
|
||||
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
|
||||
}
|
||||
img.addEventListener('error', () => img.replaceWith(img.getAttribute('alt') || src), { once: true });
|
||||
}
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!isWorkspaceRef(href)) continue;
|
||||
if (unresolvable) {
|
||||
a.replaceWith(...a.childNodes);
|
||||
continue;
|
||||
}
|
||||
a.className = 'rv-path';
|
||||
a.dataset.path = resolveRef(href);
|
||||
a.dataset.sessionId = sessionId;
|
||||
a.setAttribute('href', '#');
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('rel');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the toggles that apply to the current view: MD for a markdown file in
|
||||
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
|
||||
* image, media, PDF, edit mode) hides all three.
|
||||
*/
|
||||
_updateFilePreviewToolbar(view) {
|
||||
const set = (id, shown, pressed) => {
|
||||
const btn = this.$(id);
|
||||
if (!btn) return;
|
||||
btn.hidden = !shown;
|
||||
if (shown) btn.setAttribute('aria-pressed', String(pressed));
|
||||
};
|
||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
||||
},
|
||||
|
||||
toggleFilePreviewMd() {
|
||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
||||
this._renderFilePreviewText();
|
||||
},
|
||||
|
||||
toggleFilePreviewLines() {
|
||||
this._toggleFilePreviewTextClass('lineNumbers', 'show-lines');
|
||||
},
|
||||
|
||||
toggleFilePreviewWrap() {
|
||||
this._toggleFilePreviewTextClass('wrap', 'wrap');
|
||||
},
|
||||
|
||||
/** Lines and Wrap are pure class flips on the <pre>; no re-render needed. */
|
||||
_toggleFilePreviewTextClass(pref, className) {
|
||||
const on = !this._filePreviewPref(pref);
|
||||
this._setFilePreviewPref(pref, on);
|
||||
const pre = this.$('filePreviewBody')?.querySelector(':scope > pre.file-preview-text');
|
||||
if (pre) pre.classList.toggle(className, on);
|
||||
this._updateFilePreviewToolbar('text');
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -4318,6 +4626,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||
bodyEl.innerHTML = '';
|
||||
bodyEl.appendChild(textarea);
|
||||
// The MD/Lines/Wrap toggles act on the text view this textarea replaced.
|
||||
this._updateFilePreviewToolbar('none');
|
||||
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||
// before the user has scrolled to the line they want to change.
|
||||
|
||||
|
||||
@@ -1038,6 +1038,7 @@ 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' },
|
||||
@@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
planItems: enabledItems?.length ? enabledItems : undefined,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(advisorModel ? { advisorModel } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
/**
|
||||
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
||||
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
||||
* blocks may carry `language-*` classes from marked.
|
||||
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
|
||||
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
|
||||
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
|
||||
* README could break every button until a reload.
|
||||
*/
|
||||
var ALLOWED_ATTR = [
|
||||
'href',
|
||||
@@ -93,7 +96,6 @@
|
||||
'title',
|
||||
'class',
|
||||
'id',
|
||||
'name',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'align',
|
||||
|
||||
@@ -153,7 +153,6 @@ 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.
|
||||
@@ -162,19 +161,35 @@ 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) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
this._lineageEdgeCount = resolvedEdges.length;
|
||||
|
||||
// PHASE 2 — writes, from the cache only.
|
||||
for (const edge of edges) {
|
||||
const parentRect = rects.get('tab:' + edge.parentId);
|
||||
const childRect = rects.get('tab:' + edge.childId);
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
@@ -191,7 +206,8 @@ 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' : '';
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
// 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().
|
||||
@@ -211,7 +227,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);
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
|
||||
@@ -169,6 +169,17 @@ 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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1965,6 +1976,7 @@ 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;
|
||||
@@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
workingDir, name,
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(advisorModel ? { advisorModel } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
|
||||
@@ -416,6 +416,13 @@ 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.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 =
|
||||
@@ -527,6 +534,7 @@ 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;
|
||||
@@ -627,6 +635,7 @@ 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
|
||||
@@ -718,6 +727,7 @@ 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();
|
||||
@@ -993,8 +1003,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_buildEffortSegment() {
|
||||
const select = document.getElementById('appSettingsThinkingEffort');
|
||||
const seg = document.getElementById('appSettingsEffortSegment');
|
||||
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);
|
||||
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
|
||||
seg.innerHTML = '';
|
||||
[...select.options].forEach(opt => {
|
||||
@@ -1005,16 +1035,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.textContent = opt.textContent;
|
||||
btn.addEventListener('click', () => {
|
||||
select.value = opt.value;
|
||||
this._syncEffortSegment();
|
||||
this._syncSelectSegment(selectId, segId);
|
||||
});
|
||||
seg.appendChild(btn);
|
||||
});
|
||||
seg.dataset.built = '1';
|
||||
},
|
||||
|
||||
_syncEffortSegment() {
|
||||
const select = document.getElementById('appSettingsThinkingEffort');
|
||||
const seg = document.getElementById('appSettingsEffortSegment');
|
||||
_syncSelectSegment(selectId, segId) {
|
||||
const select = document.getElementById(selectId);
|
||||
const seg = document.getElementById(segId);
|
||||
if (!select || !seg) return;
|
||||
seg.querySelectorAll('button').forEach(btn => {
|
||||
const on = btn.dataset.value === (select.value || '');
|
||||
@@ -1114,6 +1144,226 @@ 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' : '';
|
||||
},
|
||||
|
||||
/** 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;
|
||||
}
|
||||
},
|
||||
|
||||
_setUpdateResult(html) {
|
||||
const el = this.$('updateResult');
|
||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||
@@ -2159,6 +2409,7 @@ 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,
|
||||
@@ -2214,6 +2465,7 @@ 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,
|
||||
@@ -2431,6 +2683,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
sessionLineageLines: _sll,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -2455,7 +2708,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
|
||||
this.showToast('Settings saved', 'success');
|
||||
// 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) {
|
||||
@@ -2466,7 +2728,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Settings saved locally', 'warning');
|
||||
}
|
||||
|
||||
this.closeAppSettings();
|
||||
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.
|
||||
@@ -4106,4 +4372,5 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
window.app?._applyMcpSyncAdminGate?.();
|
||||
});
|
||||
|
||||
+189
-3
@@ -460,6 +460,31 @@ textarea:focus-visible {
|
||||
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
|
||||
}
|
||||
|
||||
.xterm-helpers .codeman-ime-preview {
|
||||
position: absolute;
|
||||
left: var(--xterm-helper-left, 0px);
|
||||
top: var(--xterm-helper-top, 0px);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
color: var(--terminal-foreground, var(--text, #fff));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 1.2;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Session tab focus */
|
||||
.session-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -634,6 +659,83 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- Grouped rail (owner tab layout) ---------------------------------- */
|
||||
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
|
||||
Each group's rows sit in their OWN flex column, so the activity sort's inline
|
||||
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
||||
/* Positioned so a collapsed header can carry the tab alert ring (::before). */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 4px 6px 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-layout-group-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-layout-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-layout-group-chevron::before {
|
||||
content: '\25BE';
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Keyed on the section, not aria-expanded: a group with no open rows is a tree
|
||||
leaf with no aria-expanded at all, and its chevron must still show its state. */
|
||||
.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before {
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .header-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1707,7 +1809,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
/* A control, like its gear/detach siblings: the pointer must not read as
|
||||
"nothing here" over the one button that closes the session. */
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
/* Performance: use transform for GPU acceleration */
|
||||
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
|
||||
@@ -1760,7 +1864,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
The overlay paints above the tab's inline content (positioned vs flow), which
|
||||
is fine at these alphas and is exactly what keeps it visible over the active
|
||||
tab's opaque-ish background. */
|
||||
.session-tabs .session-tab.tab-alert-action::before {
|
||||
.session-tabs .session-tab.tab-alert-action::before,
|
||||
.session-tabs .tab-layout-group-header.tab-alert-action::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
@@ -1785,7 +1890,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.session-tabs .session-tab.tab-alert-idle::before {
|
||||
.session-tabs .session-tab.tab-alert-idle::before,
|
||||
.session-tabs .tab-layout-group-header.tab-alert-idle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
@@ -10510,6 +10616,21 @@ kbd {
|
||||
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
and dotted, so it reads as "somewhere in there" rather than a real row. */
|
||||
.connection-line.lineage-line--proxied {
|
||||
stroke-dasharray: 2 7;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.lineage-line-dot.lineage-line-dot--proxied {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@@ -10984,6 +11105,19 @@ kbd {
|
||||
.file-preview-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
/* The title yields on a phone, not the buttons. */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview-actions .btn-icon-sm[aria-pressed='true'] {
|
||||
color: var(--accent, #4ea1ff);
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.file-preview-actions .file-preview-pill {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.file-preview-body {
|
||||
@@ -11008,6 +11142,58 @@ kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---- File Viewer text view: Lines / Wrap toggles ----
|
||||
Child-combinator scoped so none of this leaks into the code blocks of a
|
||||
rendered markdown document, which are <pre>s too. */
|
||||
|
||||
.file-preview-body > pre.file-preview-text {
|
||||
counter-reset: fp-line;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.file-preview-body > pre.file-preview-text:not(.wrap) {
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Numbers hug the left edge (4px, left-aligned) instead of sitting behind the
|
||||
pre's own padding right-aligned in a 4ch column, where "1" landed 40px in. */
|
||||
.file-preview-body > pre.file-preview-text.show-lines {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-preview-body > pre.file-preview-text.show-lines .fp-line::before {
|
||||
counter-increment: fp-line;
|
||||
content: counter(fp-line);
|
||||
display: inline-block;
|
||||
min-width: 4ch;
|
||||
margin-right: 1ch;
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- File Viewer rendered markdown ----
|
||||
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
|
||||
carries no padding or base font (the chat card supplies those). */
|
||||
|
||||
.file-preview-body > .file-preview-md {
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
/* Relative links in the document are rebased onto a.rv-path so the Response
|
||||
Viewer delegate opens them here; keep them reading as prose, not as paths.
|
||||
Three-class selector on purpose: `.rv-text a.rv-path` (the monospace path
|
||||
style) is declared later in this file and ties on specificity otherwise. */
|
||||
.file-preview-body .file-preview-md a.rv-path {
|
||||
font: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.file-preview-body img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @fileoverview Read-only browser projection 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 three 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.
|
||||
*
|
||||
* 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))];
|
||||
const copyRefs = (value) =>
|
||||
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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)">` +
|
||||
'<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></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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.CodemanTabLayout = {
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
project,
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -14,6 +14,9 @@
|
||||
*/
|
||||
|
||||
(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
|
||||
@@ -68,10 +71,21 @@
|
||||
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 from the pull's response until its finally
|
||||
// block: live frames are held there with their arrival time instead of
|
||||
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
||||
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
||||
this._historyPullAt = 0;
|
||||
this._historyPullUseless = false;
|
||||
this._liveQueue = null;
|
||||
this._markerOwed = false;
|
||||
this._onWheel = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -95,6 +109,8 @@
|
||||
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 }));
|
||||
@@ -156,14 +172,17 @@
|
||||
// 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. */
|
||||
});
|
||||
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
||||
if (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
|
||||
@@ -254,9 +273,9 @@
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
this._onLiveOutput(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
this._onLiveClear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
@@ -280,16 +299,49 @@
|
||||
// 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._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
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 any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
||||
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
||||
// written now, it would sit above the output a pull is still holding (flushed
|
||||
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
||||
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
||||
// response holds the marker too, for as long as the request takes (up to its
|
||||
// budget, see _pullHistory()).
|
||||
_onSocketClosed() {
|
||||
this._wsReady = false;
|
||||
this._wsClosed = true;
|
||||
if (this._bufferLoading) this._markerOwed = true;
|
||||
else this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Settles a marker the pane owes: set when a close lands during a load (the
|
||||
// replay would otherwise sit below it) or when a load wipes the terminal on
|
||||
// a closed socket. Called from each load's own finally, just before
|
||||
// _endBufferLoad() starts any trailing refresh.
|
||||
_stampMarkerIfOwed() {
|
||||
// A trailing refresh is about to clear() synchronously, while xterm parses
|
||||
// a write() on a later tick: a marker written here would land in the
|
||||
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
||||
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
||||
if (this._bufferRefreshPending && !this._destroyed) return;
|
||||
const owed = this._markerOwed;
|
||||
this._markerOwed = false;
|
||||
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
||||
// closed socket can write it (see _stampMarkerIfOwed()).
|
||||
_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
|
||||
@@ -324,14 +376,187 @@
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
this._stampMarkerIfOwed();
|
||||
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() {
|
||||
this._bufferLoading = true;
|
||||
let replayed = false;
|
||||
let capturedAt = 0;
|
||||
// Two budgets on one signal. The request itself gets the primary pane's
|
||||
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
||||
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
||||
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
||||
// for that whole budget. Once the headers land live output IS held, so the
|
||||
// body read gets the short one instead: a body that hangs would otherwise
|
||||
// freeze the pane for the long budget. Aborting lands in the catch below,
|
||||
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
||||
// be re-armed, hence the controller; without AbortController the pull
|
||||
// simply has no deadline.
|
||||
const controller = global.AbortController ? new global.AbortController() : null;
|
||||
let abortTimer = null;
|
||||
const armDeadline = (ms) => {
|
||||
if (!controller) return;
|
||||
clearTimeout(abortTimer);
|
||||
abortTimer = setTimeout(() => controller.abort(), ms);
|
||||
};
|
||||
try {
|
||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: controller?.signal,
|
||||
});
|
||||
armDeadline(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();
|
||||
// Opened only now: a frame from before the response is either replaced by
|
||||
// the capture or written unchanged, so holding it for the round trip
|
||||
// bought nothing and froze the pane for as long as the fetch took.
|
||||
this._liveQueue = [];
|
||||
const payload = (await res.json())?.data;
|
||||
clearTimeout(abortTimer);
|
||||
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;
|
||||
if (this._wsClosed) this._markerOwed = 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 {
|
||||
clearTimeout(abortTimer);
|
||||
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);
|
||||
}
|
||||
// Settled after the queue flush so the marker is the last thing on
|
||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
||||
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
||||
// one written before it, which would paint a fresh, current-looking
|
||||
// history while onData keeps silently dropping every keystroke on the
|
||||
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
||||
// is left to that refresh, which writes it below its own replay.
|
||||
this._stampMarkerIfOwed();
|
||||
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
|
||||
@@ -344,6 +569,10 @@
|
||||
return;
|
||||
}
|
||||
this.terminal?.clear();
|
||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
||||
// trailing refresh behind a pull that the socket's close then interrupts),
|
||||
// so a refresh on a closed socket owes it back once its replay is written.
|
||||
if (this._wsClosed) this._markerOwed = true;
|
||||
void this._loadBuffer();
|
||||
}
|
||||
|
||||
@@ -381,6 +610,10 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
// (_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',
|
||||
@@ -64,6 +67,7 @@
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="treeitem"]',
|
||||
'.keyboard-accessory-bar',
|
||||
'.path-picker-overlay',
|
||||
].join(',');
|
||||
@@ -250,6 +254,235 @@ 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.
|
||||
@@ -307,6 +540,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._initMobileImePreview();
|
||||
this._installMobileTapMouseGuard();
|
||||
this._installShiftDragSelection();
|
||||
this._installTouchSelectionFocusGuard();
|
||||
@@ -447,8 +681,11 @@ 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.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
if (this.activeSessionId) {
|
||||
// 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 (this._localEchoEnabled) {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
@@ -1170,9 +1407,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// has to re-resolve their gate before the redraw, not just move them.
|
||||
this.applyLineageLineSettings?.();
|
||||
this.updateConnectionLines();
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// 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();
|
||||
// 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
|
||||
@@ -1212,6 +1450,8 @@ 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
|
||||
@@ -1289,6 +1529,8 @@ 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();
|
||||
@@ -1345,6 +1587,7 @@ 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) {
|
||||
@@ -1490,6 +1733,7 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -3155,6 +3399,7 @@ 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
|
||||
@@ -3204,6 +3449,8 @@ 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();
|
||||
@@ -3522,6 +3769,9 @@ 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.
|
||||
@@ -3585,6 +3835,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Accumulate raw data (may contain DEC 2026 markers)
|
||||
this.pendingWrites.push(data);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
@@ -3616,6 +3867,7 @@ 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;
|
||||
|
||||
@@ -3646,6 +3898,15 @@ 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();
|
||||
@@ -3851,6 +4112,9 @@ 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 {
|
||||
@@ -3868,6 +4132,7 @@ 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;
|
||||
@@ -3897,9 +4162,10 @@ 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.
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// 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();
|
||||
|
||||
// After Tab completion: detect the completed text in the overlay.
|
||||
// Use terminal.write('', callback) to defer detection until xterm.js
|
||||
|
||||
@@ -274,7 +274,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// one `/`, and refuse whatever still opens a second one. The proxied form
|
||||
// is refused server-side as well (resolveUpstreamUrl).
|
||||
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
|
||||
void this.openWebview(id, { path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/' });
|
||||
void this.openWebview(id, {
|
||||
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
|
||||
auto: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -301,13 +304,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
if (!this.webviews.get(id)) continue;
|
||||
parts.push(this.renderWebviewTab(id, idx));
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
/**
|
||||
* 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 '';
|
||||
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)}"
|
||||
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)}">
|
||||
@@ -319,10 +335,7 @@ 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>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
@@ -345,6 +358,7 @@ 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 ─────────────────────────────────────────────────────
|
||||
@@ -356,15 +370,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||
* @param {{path?: string, auto?: boolean}} [options] `path` (pathname+search+hash) opens a
|
||||
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
||||
* against the real URL in direct mode. A mounted frame is navigated there
|
||||
* rather than left on whatever page it was showing.
|
||||
* rather than left on whatever page it was showing. `auto: true` marks an
|
||||
* open the APP made (a frame recovering itself, the fallback after the
|
||||
* active web tab closes), as on selectSession().
|
||||
*/
|
||||
async openWebview(id, options = {}) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
// Opening a web tab yourself is choosing something else, so a
|
||||
// `#session=<id>` link still waiting for its session must not take the
|
||||
// screen from this tab later. Retired before the await below, which a
|
||||
// session:created could otherwise land inside.
|
||||
if (options.auto !== true) this._retireUrlSession?.();
|
||||
|
||||
if (!this.webviewOrder.includes(id)) {
|
||||
this.webviewOrder.push(id);
|
||||
this._persistWebviewOrder();
|
||||
@@ -513,7 +535,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
this.openWebview(next, { auto: true });
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
* 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 and the lineage.
|
||||
* name, the env overrides, the effort, the model, the advisor model 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
|
||||
|
||||
@@ -1724,7 +1724,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
||||
// so the file viewer can open the same files.
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico']);
|
||||
// Shared with the attachment registry so a video plays the same whether it
|
||||
// sits in the workspace or is reached by id from outside it.
|
||||
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
|
||||
@@ -2045,6 +2045,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
mp4: 'video/mp4',
|
||||
|
||||
@@ -28,6 +28,8 @@ 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 {
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @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,6 +293,7 @@ export function registerRalphRoutes(
|
||||
planItems,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
} = parseBody(RalphLoopStartSchema, req.body);
|
||||
|
||||
// Multi-user: cases live in the requesting user's space.
|
||||
@@ -343,6 +344,7 @@ export function registerRalphRoutes(
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
owner: rlOwner,
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,8 @@ 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,
|
||||
|
||||
@@ -30,7 +30,13 @@ import {
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import {
|
||||
Session,
|
||||
cliTakesSessionModel,
|
||||
isAltScreenStripMode,
|
||||
isExternalCliMode,
|
||||
isMuxAltScreenOnlyStripMode,
|
||||
} from '../../session.js';
|
||||
import type { PaneCaptureOptions } from '../../mux-interface.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
@@ -107,6 +113,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
@@ -888,6 +895,25 @@ export function registerSessionRoutes(
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
|
||||
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
|
||||
// dropped without a word; refuse it before anything is written for the session.
|
||||
if (body.model && !cliTakesSessionModel(body.mode ?? 'claude')) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
|
||||
);
|
||||
}
|
||||
// An attach launches nothing (the remote agent is already running), so a launch model
|
||||
// or advisor would be dropped the same way, so both are refused, as they have been since
|
||||
// they were added. The older launch fields (effort, envOverrides) predate this and keep
|
||||
// their silent ignore here, since refusing them now would break existing callers.
|
||||
if (body.attachRemoteSession && (body.model || body.advisorModel)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'model and advisorModel shape a new launch, and attachRemoteSession launches nothing; leave them out when attaching'
|
||||
);
|
||||
}
|
||||
let workingDir = body.workingDir || process.cwd();
|
||||
let remote = undefined;
|
||||
|
||||
@@ -1073,9 +1099,10 @@ export function registerSessionRoutes(
|
||||
// genuinely different mechanisms:
|
||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||
// in that CLI's own config object.
|
||||
// 'claude-settings-file' — claude alone, whose model is written to
|
||||
// <case>/.claude/settings.local.json rather than passed as
|
||||
// a flag, so the app-wide default applies here.
|
||||
// 'claude-settings-file' — claude alone, whose persistent model is written to
|
||||
// <case>/.claude/settings.local.json (`modelOverride`). A
|
||||
// per-session `model` from the caller goes out as --model and
|
||||
// wins; without one, the app-wide default applies.
|
||||
// 'none' — shell has no model; deepseek's is a composition entry in
|
||||
// the profile's config tree, not a session field
|
||||
// (docs/deepseek-integration.md). Both get nothing.
|
||||
@@ -1086,7 +1113,7 @@ export function registerSessionRoutes(
|
||||
| string
|
||||
| undefined)
|
||||
: modelSource?.source === 'claude-settings-file'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
? body.model || modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
@@ -1130,6 +1157,7 @@ export function registerSessionRoutes(
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
advisorModel: body.advisorModel,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
owner,
|
||||
@@ -2092,21 +2120,16 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|
||||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||||
// Uses send-keys -H (hex) to inject a newline chord: 0x0a (line feed) by default, or the CLI's
|
||||
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
|
||||
// 0x0d (carriage return = submit).
|
||||
|
||||
app.post('/api/sessions/:id/send-key', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const key = typeof body?.key === 'string' ? body.key : '';
|
||||
|
||||
// Map key names to hex byte sequences
|
||||
const KEY_HEX_MAP: Record<string, string[]> = {
|
||||
'S-Enter': ['0a'], // \n (line feed)
|
||||
'C-Enter': ['0a'], // \n (line feed)
|
||||
};
|
||||
const hex = KEY_HEX_MAP[key];
|
||||
if (!hex) {
|
||||
if (key !== 'S-Enter' && key !== 'C-Enter') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
@@ -2116,6 +2139,18 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
}
|
||||
|
||||
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
|
||||
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
|
||||
// Esc+Enter declares it in the registry instead of being special-cased here.
|
||||
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
|
||||
'line-feed': ['0a'], // \n
|
||||
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
|
||||
};
|
||||
const hex =
|
||||
key === 'C-Enter'
|
||||
? NEWLINE_HEX['line-feed']
|
||||
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
|
||||
|
||||
try {
|
||||
// Route through the dedicated Codeman socket — bare `tmux` would target the
|
||||
// user's default server and never find this session (same #80 regression class).
|
||||
@@ -3393,6 +3428,7 @@ export function registerSessionRoutes(
|
||||
ompConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
parentSessionId,
|
||||
agentOrigin,
|
||||
customModel,
|
||||
@@ -3440,6 +3476,7 @@ export function registerSessionRoutes(
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
advisorModel ||
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
@@ -3453,7 +3490,7 @@ export function registerSessionRoutes(
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3510,6 +3547,7 @@ export function registerSessionRoutes(
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
advisorModel ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
@@ -3522,7 +3560,7 @@ export function registerSessionRoutes(
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||||
'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3977,6 +4015,7 @@ export function registerSessionRoutes(
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
docker,
|
||||
resumeSessionId: dockerResumeId,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
|
||||
*
|
||||
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
|
||||
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
|
||||
* POST /api/webhook/test — send one test message with the saved config
|
||||
*
|
||||
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
|
||||
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
|
||||
* the channel receives every session's events, the same reach an admin's own Web Push has.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type WebhookResult,
|
||||
type WebhookStatus,
|
||||
} from '../../types.js';
|
||||
import { isAdmin, parseBody } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { WebhookUpdateSchema } from '../schemas.js';
|
||||
import {
|
||||
maskWebhookUrl,
|
||||
readWebhookConfig,
|
||||
webhookUrlProblem,
|
||||
writeWebhookConfig,
|
||||
type WebhookNotifier,
|
||||
} from '../../webhook-notify.js';
|
||||
|
||||
export interface WebhookRouteDeps {
|
||||
notifier: WebhookNotifier;
|
||||
configDir: string;
|
||||
/** The instance's window title, so a test message says which machine sent it. */
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
|
||||
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const status = async (): Promise<WebhookStatus> => {
|
||||
const cfg = await readWebhookConfig(deps.configDir);
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
kind: cfg.kind,
|
||||
scope: cfg.scope,
|
||||
hasUrl: cfg.url !== '',
|
||||
urlMasked: maskWebhookUrl(cfg.url),
|
||||
lastResult: deps.notifier.lastResult,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
|
||||
const current = await readWebhookConfig(deps.configDir);
|
||||
const next = {
|
||||
enabled: patch.enabled ?? current.enabled,
|
||||
kind: patch.kind ?? current.kind,
|
||||
scope: patch.scope ?? current.scope,
|
||||
url: patch.url !== undefined ? patch.url.trim() : current.url,
|
||||
};
|
||||
if (next.url) {
|
||||
const problem = webhookUrlProblem(next.url);
|
||||
if (problem) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
}
|
||||
if (next.enabled && !next.url) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
|
||||
}
|
||||
try {
|
||||
await writeWebhookConfig(deps.configDir, next);
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const cfg = await readWebhookConfig(deps.configDir);
|
||||
if (!cfg.url) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
|
||||
}
|
||||
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
|
||||
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
|
||||
});
|
||||
}
|
||||
@@ -18,11 +18,14 @@ import {
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
|
||||
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
import { isAdvisorModel } from '../types/session.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -251,6 +254,20 @@ const safeEnvOverridesSchema = z
|
||||
*/
|
||||
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
|
||||
|
||||
/**
|
||||
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
|
||||
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
|
||||
* a soft default that /advisor still switches in-session. The allowlist is also the injection
|
||||
* guard for the single-quoted `--settings` argument.
|
||||
*/
|
||||
const advisorModelSchema = z
|
||||
.string()
|
||||
.max(64)
|
||||
.refine((value) => isAdvisorModel(value), {
|
||||
message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id',
|
||||
})
|
||||
.optional();
|
||||
|
||||
// ========== Session Routes ==========
|
||||
|
||||
/**
|
||||
@@ -298,6 +315,7 @@ const CodexConfigSchema = z
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(),
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
@@ -524,8 +542,25 @@ export const CreateSessionSchema = z.object({
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
|
||||
advisorModel: advisorModelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/**
|
||||
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
|
||||
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
|
||||
* pattern, so a value accepted here is never rejected at launch. The first character must be
|
||||
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
|
||||
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
|
||||
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
|
||||
* it for any other CLI and on a remote attach.
|
||||
*/
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/)
|
||||
.or(z.literal(''))
|
||||
.optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
@@ -1055,6 +1090,8 @@ export const QuickStartSchema = z.object({
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
|
||||
advisorModel: advisorModelSchema,
|
||||
/**
|
||||
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
|
||||
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
|
||||
@@ -1327,6 +1364,13 @@ export const SettingsUpdateSchema = z
|
||||
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
|
||||
*/
|
||||
cliManagementEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
|
||||
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
|
||||
* the server user's home (including any env values and headers on the servers), so it is
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
@@ -1374,6 +1418,12 @@ export const SettingsUpdateSchema = z
|
||||
// auto-reattached.
|
||||
remoteAutoReconnect: z.boolean().optional(),
|
||||
thinkingEffort: z.string().max(20).optional(),
|
||||
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
|
||||
claudeAdvisorModel: z
|
||||
.string()
|
||||
.max(64)
|
||||
.refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' })
|
||||
.optional(),
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
showSystemStats: z.boolean().optional(),
|
||||
@@ -1846,6 +1896,20 @@ export const PushPreferencesUpdateSchema = z.object({
|
||||
pushPreferences: z.record(z.string(), z.boolean()),
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
|
||||
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
|
||||
* lists are the store's own, so the schema can never accept a value the store would coerce away.
|
||||
*/
|
||||
export const WebhookUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
kind: z.enum(WEBHOOK_KINDS).optional(),
|
||||
scope: z.enum(WEBHOOK_SCOPES).optional(),
|
||||
url: z.string().max(2048).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Ralph Loop ==========
|
||||
|
||||
/** POST /api/ralph-loop/start */
|
||||
@@ -1862,6 +1926,8 @@ export const RalphLoopStartSchema = z.object({
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
|
||||
advisorModel: advisorModelSchema,
|
||||
planItems: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
+56
-22
@@ -44,6 +44,9 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
|
||||
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
|
||||
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
|
||||
import type { WebhookUrgency } from '../types/push.js';
|
||||
import { webviewFetch } from './webview-egress.js';
|
||||
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
|
||||
import type { RemoteWakeRegistry } from '../remote-wake.js';
|
||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||
@@ -197,6 +200,8 @@ import {
|
||||
registerVoiceRoutes,
|
||||
registerWebviewRoutes,
|
||||
registerTabLayoutRoutes,
|
||||
registerMcpSyncRoutes,
|
||||
registerWebhookRoutes,
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
readCustomModelEndpointsEnabled,
|
||||
@@ -368,6 +373,8 @@ export class WebServer extends EventEmitter {
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private userFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
|
||||
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private readonly titleHostname: string;
|
||||
@@ -1130,6 +1137,12 @@ export class WebServer extends EventEmitter {
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerMcpSyncRoutes(this.app);
|
||||
registerWebhookRoutes(this.app, {
|
||||
notifier: this.webhookNotifier,
|
||||
configDir: getDataDir(),
|
||||
hostTitle: () => this.windowTitle,
|
||||
});
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
|
||||
@@ -2652,6 +2665,25 @@ export class WebServer extends EventEmitter {
|
||||
const template = WebServer.PUSH_EVENT_MAP[event];
|
||||
if (!template) return;
|
||||
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
const body = WebServer.pushBodyText(event, data, sessionName);
|
||||
|
||||
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
|
||||
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
|
||||
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
|
||||
void this.webhookNotifier
|
||||
.notify({
|
||||
event,
|
||||
title: template.title,
|
||||
body,
|
||||
urgency: template.urgency as WebhookUrgency,
|
||||
sessionId: sessionId || undefined,
|
||||
sessionName: sessionName || undefined,
|
||||
host: this.windowTitle,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscriptions = this.pushStore.getAll();
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
@@ -2670,9 +2702,6 @@ export class WebServer extends EventEmitter {
|
||||
const vapidKeys = this.pushStore.getVapidKeys();
|
||||
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
|
||||
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
|
||||
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
||||
// must reach only the owner's devices (+ admins) — the body embeds the session
|
||||
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
||||
@@ -2680,25 +2709,6 @@ export class WebServer extends EventEmitter {
|
||||
const multiUserPush = isMultiUserMode();
|
||||
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: template.title,
|
||||
// Hostname-aware prefix so OS-level notifications from multiple Codeman
|
||||
@@ -2752,6 +2762,28 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** The notification body for an event (shared by Web Push and the webhook channel). */
|
||||
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private cleanupDeadSSEClients(): void {
|
||||
this.sse.cleanupDeadClients();
|
||||
}
|
||||
@@ -3489,6 +3521,8 @@ export class WebServer extends EventEmitter {
|
||||
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
model: savedState?.model,
|
||||
advisorModel: savedState?.advisorModel,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
// The pane's last Enter. Without it the response viewer would show
|
||||
// the launch conversation until the user types again, even though
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @fileoverview Webhook notifications (ntfy, Slack, Discord, generic JSON) for the events that
|
||||
* already trigger Web Push, so a headless server can reach a phone without a browser tab or a
|
||||
* push subscription.
|
||||
*
|
||||
* Split in three, so the parts that matter are testable without a network:
|
||||
* - pure: `webhookUrlProblem`, `maskWebhookUrl`, `shouldSendWebhook`, `buildWebhookRequest`
|
||||
* - store: `~/.codeman/webhook.json`, written 0600 via tmp+rename (the URL is a bearer secret:
|
||||
* anyone holding a Slack/Discord webhook URL can post as it)
|
||||
* - IO: `sendWebhook` (injected fetch) and `WebhookNotifier` (dedupe, in-flight cap, last result)
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - The URL is configured only through the admin-only `/api/webhook` routes and kept OUT of
|
||||
* `settings.json`, which every logged-in user can read through `GET /api/settings`.
|
||||
* - Delivery goes through `webviewFetch`: link-local and cloud-metadata targets are refused on
|
||||
* the RESOLVED address at connect time, redirects are not followed, and the call is bounded
|
||||
* by a timeout. Loopback and LAN stay allowed on purpose (a local ntfy is the feature).
|
||||
* - The URL never appears in a log line, a result, or an error message.
|
||||
* - Session names and error text are user/agent-controlled, so they cannot ping a channel:
|
||||
* Discord gets `allowed_mentions: { parse: [] }` and Slack control characters are escaped.
|
||||
*
|
||||
* @module webhook-notify
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { blockedWebviewHostReason } from './web/webview-egress-policy.js';
|
||||
import { isEgressBlockedError } from './web/webview-egress.js';
|
||||
import {
|
||||
WEBHOOK_KINDS,
|
||||
WEBHOOK_SCOPES,
|
||||
type WebhookConfig,
|
||||
type WebhookKind,
|
||||
type WebhookResult,
|
||||
type WebhookScope,
|
||||
type WebhookUrgency,
|
||||
} from './types/push.js';
|
||||
|
||||
const WEBHOOK_FILE = 'webhook.json';
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const SEND_TIMEOUT_MS = 5000;
|
||||
const MAX_BODY_CHARS = 500;
|
||||
/** Same event + session within this window is sent once: a flapping prompt must not flood a channel. */
|
||||
const DEDUPE_WINDOW_MS = 3000;
|
||||
const MAX_IN_FLIGHT = 5;
|
||||
|
||||
export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = { enabled: false, kind: 'ntfy', url: '', scope: 'attention' };
|
||||
|
||||
export interface WebhookMessage {
|
||||
event: string;
|
||||
title: string;
|
||||
body: string;
|
||||
urgency: WebhookUrgency;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
/** The Codeman instance's window title, so several machines are told apart. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Why `raw` cannot be a webhook URL, or null. Used at save time; delivery re-checks the resolved address. */
|
||||
export function webhookUrlProblem(raw: string): string | null {
|
||||
if (raw.length > MAX_URL_LENGTH) return 'URL is too long';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return 'Not a valid URL';
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Only http and https URLs are allowed';
|
||||
if (url.username || url.password) return 'Put credentials in the path or a header-less token, not user:password@';
|
||||
const blocked = blockedWebviewHostReason(url.hostname);
|
||||
if (blocked) return `Refused: ${blocked}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scheme + host only: the path and query of a webhook URL are the secret. */
|
||||
export function maskWebhookUrl(raw: string): string {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return `${url.protocol}//${url.host}/•••`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSendWebhook(cfg: WebhookConfig, urgency: WebhookUrgency): boolean {
|
||||
if (!cfg.enabled || !cfg.url) return false;
|
||||
return cfg.scope === 'all' || urgency !== 'info';
|
||||
}
|
||||
|
||||
const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
||||
|
||||
/** A header value must be single-line printable ASCII; anything else goes out RFC 2047 encoded. */
|
||||
function headerSafe(value: string): string {
|
||||
const oneLine = value.replace(/[\r\n]+/g, ' ').trim();
|
||||
return /^[\x20-\x7e]*$/.test(oneLine) ? oneLine : `=?UTF-8?B?${Buffer.from(oneLine, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
/** Slack parses `<!channel>`, `<@U123>` and `<url|text>`; escaping the three control characters turns them to text. */
|
||||
const slackEscape = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const NTFY_PRIORITY: Record<WebhookUrgency, string> = { critical: '5', warning: '4', info: '3' };
|
||||
const NTFY_TAGS: Record<WebhookUrgency, string> = {
|
||||
critical: 'rotating_light',
|
||||
warning: 'bell',
|
||||
info: 'white_check_mark',
|
||||
};
|
||||
|
||||
export interface WebhookRequest {
|
||||
method: 'POST';
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function buildWebhookRequest(kind: WebhookKind, msg: WebhookMessage, now: Date = new Date()): WebhookRequest {
|
||||
const title = clip(msg.title, 120);
|
||||
const body = clip(msg.body, MAX_BODY_CHARS);
|
||||
const prefix = msg.host ? `${clip(msg.host, 60)}: ` : '';
|
||||
switch (kind) {
|
||||
case 'ntfy':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Title: headerSafe(`${prefix}${title}`),
|
||||
Priority: NTFY_PRIORITY[msg.urgency],
|
||||
Tags: NTFY_TAGS[msg.urgency],
|
||||
},
|
||||
body: body || title,
|
||||
};
|
||||
case 'slack':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: `*${slackEscape(`${prefix}${title}`)}*${body ? `\n${slackEscape(body)}` : ''}` }),
|
||||
};
|
||||
case 'discord':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: clip(`**${prefix}${title}**${body ? `\n${body}` : ''}`, 1900),
|
||||
// Agent output and session names are not trusted to @everyone a channel.
|
||||
allowed_mentions: { parse: [] },
|
||||
}),
|
||||
};
|
||||
case 'generic':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: msg.event,
|
||||
title,
|
||||
body,
|
||||
urgency: msg.urgency,
|
||||
sessionId: msg.sessionId ?? null,
|
||||
sessionName: msg.sessionName ?? null,
|
||||
host: msg.host ?? null,
|
||||
at: now.toISOString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function webhookConfigPath(configDir: string): string {
|
||||
return join(configDir, WEBHOOK_FILE);
|
||||
}
|
||||
|
||||
function coerce(raw: unknown): WebhookConfig {
|
||||
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
return {
|
||||
enabled: r.enabled === true,
|
||||
kind: (WEBHOOK_KINDS as readonly unknown[]).includes(r.kind)
|
||||
? (r.kind as WebhookKind)
|
||||
: DEFAULT_WEBHOOK_CONFIG.kind,
|
||||
url: typeof r.url === 'string' ? r.url : '',
|
||||
scope: (WEBHOOK_SCOPES as readonly unknown[]).includes(r.scope)
|
||||
? (r.scope as WebhookScope)
|
||||
: DEFAULT_WEBHOOK_CONFIG.scope,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWebhookConfig(configDir: string): Promise<WebhookConfig> {
|
||||
try {
|
||||
return coerce(JSON.parse(await fs.readFile(webhookConfigPath(configDir), 'utf-8')));
|
||||
} catch {
|
||||
return { ...DEFAULT_WEBHOOK_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
|
||||
export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
const target = webhookConfigPath(configDir);
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(coerce(cfg), null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmp).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WebhookFetch = (target: URL, init: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* What went wrong, without the URL: blocked / timed out / refused / an HTTP status. An egress
|
||||
* refusal is recognised by its `CODEMAN_EGRESS_BLOCKED` code anywhere in the cause chain (undici
|
||||
* wraps the lookup's error as `TypeError('fetch failed', { cause })`), never by message text.
|
||||
*/
|
||||
function describeError(err: unknown): string {
|
||||
const e = err as { name?: string; cause?: { code?: string } };
|
||||
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return 'Timed out';
|
||||
if (isEgressBlockedError(err)) return 'Refused: target is a link-local or cloud-metadata address';
|
||||
if (e?.cause?.code === 'ENOTFOUND') return 'Host not found';
|
||||
if (e?.cause?.code === 'ECONNREFUSED') return 'Connection refused';
|
||||
return 'Network error';
|
||||
}
|
||||
|
||||
export async function sendWebhook(
|
||||
cfg: Pick<WebhookConfig, 'kind' | 'url'>,
|
||||
msg: WebhookMessage,
|
||||
fetchImpl: WebhookFetch
|
||||
): Promise<WebhookResult> {
|
||||
const at = Date.now();
|
||||
const problem = webhookUrlProblem(cfg.url);
|
||||
if (problem) return { ok: false, error: problem, at };
|
||||
const req = buildWebhookRequest(cfg.kind, msg);
|
||||
try {
|
||||
const res = await fetchImpl(new URL(cfg.url), {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||
});
|
||||
void res.body?.cancel().catch(() => undefined);
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
return { ok: false, status: res.status, error: 'The URL redirects; use the final URL', at };
|
||||
}
|
||||
return res.ok
|
||||
? { ok: true, status: res.status, at }
|
||||
: { ok: false, status: res.status, error: `HTTP ${res.status}`, at };
|
||||
} catch (err) {
|
||||
return { ok: false, error: describeError(err), at };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the notifications the server decides on. Fire-and-forget by design (a slow webhook must
|
||||
* never delay Web Push or a request), so it dedupes, caps what is in flight, and remembers only
|
||||
* the last result for the Settings status line.
|
||||
*/
|
||||
export class WebhookNotifier {
|
||||
private lastSent = new Map<string, number>();
|
||||
private inFlight = 0;
|
||||
private last: WebhookResult | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly load: () => Promise<WebhookConfig>,
|
||||
private readonly fetchImpl: WebhookFetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
get lastResult(): WebhookResult | null {
|
||||
return this.last;
|
||||
}
|
||||
|
||||
async notify(msg: WebhookMessage): Promise<void> {
|
||||
const cfg = await this.load();
|
||||
if (!shouldSendWebhook(cfg, msg.urgency)) return;
|
||||
const key = `${msg.event}:${msg.sessionId ?? ''}`;
|
||||
const t = this.now();
|
||||
const prev = this.lastSent.get(key);
|
||||
if (prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return;
|
||||
if (this.inFlight >= MAX_IN_FLIGHT) return;
|
||||
this.lastSent.set(key, t);
|
||||
if (this.lastSent.size > 256) {
|
||||
for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
|
||||
}
|
||||
this.inFlight++;
|
||||
try {
|
||||
this.last = await sendWebhook(cfg, msg, this.fetchImpl);
|
||||
} finally {
|
||||
this.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
/** A deliberate test send: bypasses `enabled`, scope and dedupe, and records the result. */
|
||||
async sendTest(cfg: Pick<WebhookConfig, 'kind' | 'url'>, host?: string): Promise<WebhookResult> {
|
||||
const result = await sendWebhook(
|
||||
cfg,
|
||||
{
|
||||
event: 'webhook:test',
|
||||
title: 'Codeman test notification',
|
||||
body: 'If you can read this, webhook notifications are working.',
|
||||
urgency: 'info',
|
||||
host,
|
||||
},
|
||||
this.fetchImpl
|
||||
);
|
||||
this.last = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @fileoverview Tests for Claude Code's advisor tool (code.claude.com/docs/en/advisor)
|
||||
* carried as a per-session `advisorModel`.
|
||||
*
|
||||
* The advisor rides the launch's ONE `--settings` JSON object as the `advisorModel` key,
|
||||
* never the `--advisor` flag: the flag exits at launch on a pairing the CLI refuses
|
||||
* (`claude --advisor haiku` prints "cannot be used as an advisor" and exits 1), while the
|
||||
* settings key degrades to "no advisor". Verified against Claude Code 2.1.289 with
|
||||
* `claude -p --settings '{"advisorModel":"opus"}' /advisor` → "Advisor: Opus 5.5".
|
||||
*
|
||||
* `--settings` is extracted through a REAL shell, as in statusline-cli-flag.test.ts, so the
|
||||
* assertions see exactly what a spawned pane would.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { isAdvisorModel, ADVISOR_MODEL_ALIASES } from '../src/types.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
QuickStartSchema,
|
||||
RalphLoopStartSchema,
|
||||
SettingsUpdateSchema,
|
||||
} from '../src/web/schemas.js';
|
||||
|
||||
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
|
||||
|
||||
/**
|
||||
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
|
||||
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
|
||||
* stand-in is a real process so `Session.stop()` has a real pid to signal.
|
||||
*/
|
||||
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
|
||||
vi.mock('node-pty', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node-pty')>();
|
||||
return {
|
||||
...real,
|
||||
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
|
||||
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
|
||||
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function extractSettingsJson(cmd: string): unknown {
|
||||
const idx = cmd.indexOf('--settings ');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
const out = execFileSync('bash', ['-c', `set -- ${cmd.slice(idx)}; printf '%s' "$2"`]).toString();
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
describe('isAdvisorModel', () => {
|
||||
it('accepts the documented aliases', () => {
|
||||
for (const alias of ADVISOR_MODEL_ALIASES) expect(isAdvisorModel(alias)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts full model ids in the advisor-capable families', () => {
|
||||
expect(isAdvisorModel('claude-opus-5-5')).toBe(true);
|
||||
expect(isAdvisorModel('claude-fable-5-1')).toBe(true);
|
||||
expect(isAdvisorModel('claude-sonnet-5-5')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects haiku, which can call an advisor but never act as one', () => {
|
||||
expect(isAdvisorModel('haiku')).toBe(false);
|
||||
expect(isAdvisorModel('claude-haiku-4-5-20251001')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects anything that could break out of the quoted --settings argument', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
'OPUS',
|
||||
'opus[1m]',
|
||||
"opus'; rm -rf /; '",
|
||||
'opus"}',
|
||||
'claude-opus-5-5 --dangerously-skip-permissions',
|
||||
`claude-opus-${'5-'.repeat(40)}5`,
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
]) {
|
||||
expect(isAdvisorModel(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdvisorSettings', () => {
|
||||
it('returns the settings key for a valid model and nothing otherwise', () => {
|
||||
expect(buildAdvisorSettings('opus')).toEqual({ advisorModel: 'opus' });
|
||||
expect(buildAdvisorSettings(undefined)).toEqual({});
|
||||
expect(buildAdvisorSettings('haiku')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand advisorModel (tmux launch, claude mode)', () => {
|
||||
it('rides --settings as the advisorModel key, never the --advisor flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', advisorModel: 'opus' });
|
||||
expect(cmd).not.toContain('--advisor');
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it('merges ultracode, advisor and the statusLine exporter into ONE --settings object', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
});
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a regular --effort flag beside the advisor settings', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', effort: 'high', advisorModel: 'sonnet' });
|
||||
expect(cmd).toContain("--effort 'high'");
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'sonnet' });
|
||||
});
|
||||
|
||||
it('carries the advisor on the resume variant too', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
resumeSessionId: '11111111-2222-3333-4444-555555555555',
|
||||
advisorModel: 'opus',
|
||||
});
|
||||
expect(cmd).toContain('--resume');
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it('leaves the command byte-identical when no advisor (or an invalid one) is set', () => {
|
||||
for (const base of [
|
||||
{ mode: 'claude', sessionId: 'sid-1' },
|
||||
{ mode: 'claude', sessionId: 'sid-1', effort: 'ultracode' as const, statusLineCommand: EXPORTER_CMD },
|
||||
]) {
|
||||
const without = buildSpawnCommand(base);
|
||||
expect(buildSpawnCommand({ ...base, advisorModel: undefined })).toBe(without);
|
||||
expect(buildSpawnCommand({ ...base, advisorModel: 'haiku' })).toBe(without);
|
||||
}
|
||||
});
|
||||
|
||||
it('is inert for a CLI that has no --settings carrier', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'codex', sessionId: 'sid-1', advisorModel: 'opus' });
|
||||
expect(cmd).not.toContain('advisorModel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
|
||||
const settingsOf = (args: string[]) => {
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
return JSON.parse(args[args.indexOf('--settings') + 1]);
|
||||
};
|
||||
|
||||
it('adds a --settings object holding only the advisor', () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, undefined, undefined, null, 'opus');
|
||||
expect(args).not.toContain('--advisor');
|
||||
expect(settingsOf(args)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it("folds the advisor into ultracode's --settings object", () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'ultracode', undefined, null, 'fable');
|
||||
expect(settingsOf(args)).toEqual({ ultracode: true, advisorModel: 'fable' });
|
||||
});
|
||||
|
||||
it('keeps --effort beside it for a regular level', () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'max', undefined, null, 'sonnet');
|
||||
expect(args).toEqual(expect.arrayContaining(['--effort', 'max']));
|
||||
expect(settingsOf(args)).toEqual({ advisorModel: 'sonnet' });
|
||||
});
|
||||
|
||||
it('is unchanged without an advisor', () => {
|
||||
expect(buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null, undefined)).toEqual(
|
||||
buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
|
||||
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
|
||||
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
|
||||
* the same pair is pinned in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
describe('a per-session model together with an advisor', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const cases = [
|
||||
['ultracode', { ultracode: true, advisorModel: 'opus' }],
|
||||
['high', { advisorModel: 'opus' }],
|
||||
[undefined, { advisorModel: 'opus' }],
|
||||
] as const;
|
||||
|
||||
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort,
|
||||
advisorModel: 'opus',
|
||||
resumeSessionId,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
// The resume variant renders `resume || new`, so the model appears once per branch.
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
const settingsFlags = cmd.match(/--settings /g) ?? [];
|
||||
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
|
||||
expect(extractSettingsJson(cmd)).toEqual(settings);
|
||||
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the statusLine exporter in the same object beside the model', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
|
||||
});
|
||||
|
||||
describe('a real Session on the direct-PTY fallback', () => {
|
||||
const live: Session[] = [];
|
||||
afterEach(async () => {
|
||||
for (const s of live.splice(0)) await s.stop();
|
||||
ptySpawns.length = 0;
|
||||
});
|
||||
|
||||
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: false,
|
||||
model: MODEL,
|
||||
advisorModel: 'opus',
|
||||
effort,
|
||||
});
|
||||
live.push(session);
|
||||
await session.startInteractive();
|
||||
|
||||
expect(ptySpawns).toHaveLength(1);
|
||||
const { args } = ptySpawns[0];
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session advisorModel', () => {
|
||||
it('stores a valid advisor and persists it through toState()', () => {
|
||||
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
|
||||
expect(session.toState().advisorModel).toBe('opus');
|
||||
});
|
||||
|
||||
it('drops an invalid value instead of forwarding it to the launch', () => {
|
||||
expect(new Session({ workingDir: '/tmp', advisorModel: 'haiku' }).toState().advisorModel).toBeUndefined();
|
||||
expect(new Session({ workingDir: '/tmp' }).toState().advisorModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisorModel request validation', () => {
|
||||
it.each([
|
||||
['CreateSessionSchema', CreateSessionSchema, { workingDir: '/tmp' }],
|
||||
['QuickStartSchema', QuickStartSchema, {}],
|
||||
['RalphLoopStartSchema', RalphLoopStartSchema, { taskDescription: 'x' }],
|
||||
] as const)('%s accepts advisor models and rejects the rest', (_name, schema, base) => {
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'opus' }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'claude-fable-5-1' }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'haiku' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...base, advisorModel: "opus'" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('SettingsUpdateSchema takes claudeAdvisorModel, with "" meaning the CLI default', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: '' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'fable' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'haiku' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment node
|
||||
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
|
||||
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
describe('capabilities.newline', () => {
|
||||
it('no stock CLI declares a chord: every one keeps the line feed', () => {
|
||||
// codex 0.147.0 takes a line feed (checked against a real tmux pane), so there is no CLI that
|
||||
// needs esc-enter yet. The capability exists for a user clis.json override and the next CLI.
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => e.id as string);
|
||||
expect(declared).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(false);
|
||||
});
|
||||
|
||||
it('is optional, so an entry that declares nothing keeps the line feed', () => {
|
||||
const e = claude();
|
||||
delete (e.capabilities as Record<string, unknown>).newline;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
/** A fixed session id, so `--session-id` is stable across runs. */
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
@@ -54,6 +55,20 @@ describe('claude', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
|
||||
// POST /api/sessions refuses a leading '-' in `model`, but the registry's `model-claude`
|
||||
// pattern still admits one, so the builder must stay safe on its own: the value lands
|
||||
// quoted, and Claude's option parser takes the word after `--model` as its value whatever
|
||||
// it starts with, so it can never become a flag of its own.
|
||||
expect(claude({ model: 'claude-fable-5-1' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
|
||||
);
|
||||
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--model "--dangerously-skip-permissions"'
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes through a shell fallback to a fresh session', () => {
|
||||
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
|
||||
// text. This pin is what proves the fallback chain still renders as one command line.
|
||||
@@ -131,6 +146,18 @@ describe('codex', () => {
|
||||
it('resumes with a POSITIONAL subcommand, not a flag', () => {
|
||||
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
|
||||
});
|
||||
|
||||
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoning effort ahead of the resume subcommand', () => {
|
||||
expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe(
|
||||
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini', () => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview `codexConfig.reasoningEffort` on the create routes.
|
||||
*
|
||||
* The level becomes part of a `--config model_reasoning_effort=<level>` launch token, so the
|
||||
* schema admits only the words codex knows; anything else fails the request rather than
|
||||
* reaching the argv.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
describe('codexConfig.reasoningEffort', () => {
|
||||
it('accepts every level codex knows on both create routes', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
const created = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(created.codexConfig?.reasoningEffort).toBe(level);
|
||||
const quick = QuickStartSchema.parse({
|
||||
caseName: 'work',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(quick.codexConfig?.reasoningEffort).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => {
|
||||
for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
app.getSessionName = (session: any) =>
|
||||
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
||||
|
||||
return { app, elements, listeners };
|
||||
return { app, elements, listeners, makeClassList };
|
||||
}
|
||||
|
||||
describe('Command-K session palette', () => {
|
||||
@@ -480,6 +480,150 @@ describe('Session Manager unified list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay focus restoration (Escape must not strand the keyboard)', () => {
|
||||
/**
|
||||
* Both overlays focus their search box on open. Closing them used to leave
|
||||
* focus on <body>, so after Escape every keystroke went nowhere until the
|
||||
* user clicked the terminal — measured in a real browser against a shell
|
||||
* session: activeElement BODY, zero onData for anything typed afterwards.
|
||||
*/
|
||||
function focusHarness() {
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
|
||||
const body = { tagName: 'BODY' };
|
||||
let active: any = priorElement;
|
||||
// The harness builds its element map internally, so getElementById reads it
|
||||
// through this binding, filled in once the harness returns.
|
||||
let els: Record<string, any> = {};
|
||||
const { app, elements, makeClassList } = loadPaletteHarness({
|
||||
document: {
|
||||
getElementById: (id: string) => els[id] ?? null,
|
||||
get activeElement() {
|
||||
return active;
|
||||
},
|
||||
body,
|
||||
},
|
||||
});
|
||||
els = elements;
|
||||
app.terminal = { focus: terminalTextarea.focus };
|
||||
app.activeSessionId = 'sess-beta';
|
||||
// The overlay's own focus() is what moves focus in a real browser; the
|
||||
// fake document needs the same transition or the test proves nothing.
|
||||
elements.commandPaletteSearch.focus = vi.fn(() => {
|
||||
active = elements.commandPaletteSearch;
|
||||
});
|
||||
// The restore keeps a focus that already left the overlay, so the modal has
|
||||
// to know its own search box is inside it, as the real DOM does.
|
||||
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
|
||||
// Same wiring for the Session Manager. A real classList: the close guard
|
||||
// reads `contains('active')`, and a stub without it reports "not open" and
|
||||
// skips the restore.
|
||||
const installSessionManager = () => {
|
||||
const search: any = { value: '', addEventListener: vi.fn() };
|
||||
search.focus = vi.fn(() => {
|
||||
active = search;
|
||||
});
|
||||
elements.sessionManagerSearch = search;
|
||||
elements.sessionManagerModal = {
|
||||
classList: makeClassList(),
|
||||
addEventListener: vi.fn(),
|
||||
contains: (el: any) => el === search,
|
||||
};
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
||||
app._loadSessionManagerList = vi.fn();
|
||||
};
|
||||
return {
|
||||
app,
|
||||
elements,
|
||||
priorElement,
|
||||
terminalTextarea,
|
||||
body,
|
||||
makeClassList,
|
||||
installSessionManager,
|
||||
setActive: (v: any) => (active = v),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns focus to whatever had it when the command palette closes', () => {
|
||||
const { app, priorElement } = focusHarness();
|
||||
app.openCommandPalette();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
app.closeCommandPalette();
|
||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the terminal when the prior element is gone, but only with a live session', () => {
|
||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
||||
app.openCommandPalette();
|
||||
priorElement.isConnected = false;
|
||||
app.closeCommandPalette();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never focuses the terminal from the welcome screen (a phone would pop the keyboard)', () => {
|
||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
||||
app.activeSessionId = null;
|
||||
app.openCommandPalette();
|
||||
priorElement.isConnected = false;
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restore focus to <body>, which is the bug itself', () => {
|
||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
||||
setActive(body);
|
||||
app.openCommandPalette();
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves focus alone when neither overlay was open — the path every Escape takes', () => {
|
||||
// app.js's global Escape handler calls both close methods on EVERY Escape,
|
||||
// in the capture phase. Nothing was saved, so an unguarded restore would fall
|
||||
// through to the terminal and steal focus from split Pane B, from any text
|
||||
// field, and turn the inline rename's Escape into a commit.
|
||||
const { app, elements, terminalTextarea, priorElement, makeClassList } = focusHarness();
|
||||
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
|
||||
app.closeCommandPalette();
|
||||
app.closeSessionManager();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not focus the terminal on touch while the keyboard is down', () => {
|
||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
||||
app._shouldFocusTerminalForTabSwitch = () => false;
|
||||
setActive(body);
|
||||
app.openCommandPalette();
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores focus on the session manager too, not just the palette', async () => {
|
||||
const { app, priorElement, installSessionManager } = focusHarness();
|
||||
installSessionManager();
|
||||
await app.openSessionManager();
|
||||
app.closeSessionManager();
|
||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
|
||||
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
|
||||
// which focuses the terminal on desktop, and only THEN closeSessionManager().
|
||||
// Opened from its header button, the saved focus is that button, so an
|
||||
// unconditional restore pulled focus off the session the user just picked.
|
||||
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
|
||||
installSessionManager();
|
||||
await app.openSessionManager();
|
||||
app.selectSession = vi.fn(() => setActive(terminalTextarea));
|
||||
app.selectSession('sess-alpha');
|
||||
app.closeSessionManager();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel close helpers', () => {
|
||||
it('closes panels when the mobile header helper is unavailable', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* @fileoverview File Viewer text view: rendered markdown plus Lines/Wrap toggles.
|
||||
*
|
||||
* Clicking a `.md` in the Files panel showed wrapped source with no way to see
|
||||
* it rendered, although the Response Viewer's marked + DOMPurify pipeline
|
||||
* (`_renderMarkdown`) was already on the page. The viewer now renders markdown
|
||||
* through that same pipeline, with an MD toggle back to source, and the
|
||||
* plain-text view gained Lines and Wrap toggles. Pinned here:
|
||||
*
|
||||
* 1. `.md` renders into `.rv-text.file-preview-md[data-i18n-skip]` while the
|
||||
* pref is on and into a `<pre>` of per-line spans while it is off; the MD
|
||||
* toggle re-renders WITHOUT a second fetch and persists per device.
|
||||
* 2. Relative image refs are rebased onto the workspace-confined file-raw
|
||||
* route under the document's directory and a failed load degrades to alt
|
||||
* text; relative links become `a.rv-path` for the Response Viewer delegate
|
||||
* and lose the `target` marked gave them, while fragment and http(s) links
|
||||
* stay untouched.
|
||||
* 3. Markdown fetches the route's line ceiling; other text keeps 500.
|
||||
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
|
||||
* holds is byte-identical to the file; every toggle is hidden for an image
|
||||
* and while editing.
|
||||
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
|
||||
* (in-workspace text keeps the tail viewer, see architecture-invariants).
|
||||
* 6. A preview opened by attachment id under a bare file name has no
|
||||
* directory to resolve against, so its relative images degrade to alt text
|
||||
* and its relative links to plain text instead of landing on the workspace
|
||||
* root's files; an absolute-path attachment keeps resolving.
|
||||
* 7. A file renders without chat line breaks (`breaks: false`): a paragraph
|
||||
* hard-wrapped in the source is one paragraph, while the Response Viewer
|
||||
* keeps a <br> per newline.
|
||||
*
|
||||
* Loaded via `vm` with a jsdom document injected (the technique from
|
||||
* response-viewer-file-links.test.ts): constants.js + panels-ui.js only, with
|
||||
* the app.js markdown pipeline stubbed to a fixed fragment, except for rule 7,
|
||||
* which runs the shipping app.js + vendored marked + DOMPurify end to end.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const publicFile = (name: string) => readFileSync(resolve(PUBLIC, name), 'utf8');
|
||||
const constantsJs = publicFile('constants.js');
|
||||
const panelsJs = publicFile('panels-ui.js');
|
||||
|
||||
// A real origin: vitest's equality walker reaches the window through a node's
|
||||
// ownerDocument, and jsdom's localStorage getter throws on an opaque one.
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const { document } = dom.window;
|
||||
|
||||
/** What the stubbed `_renderMarkdown` hands back: every ref shape the rebase pass must classify. */
|
||||
const MARKDOWN_HTML =
|
||||
'<h1>Title</h1><p>x</p>' +
|
||||
'<img src="img/a.png#gh-dark-mode-only" alt="Alt A">' +
|
||||
'<img src="https://cdn.example.com/r.png" alt="remote">' +
|
||||
'<a href="guide/x.md#sec" target="_blank" rel="noopener noreferrer">x</a>' +
|
||||
'<a href="../CHANGELOG.md" target="_blank" rel="noopener noreferrer">up</a>' +
|
||||
'<a href="#top">t</a>' +
|
||||
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>' +
|
||||
// marked percent-encodes destinations; a query rides along on GitHub-style refs.
|
||||
'<img src="my%20image.png" alt="space">' +
|
||||
'<img src="raw.png?raw=true" alt="raw">' +
|
||||
'<img src="bad%zz.png" alt="bad">' +
|
||||
'<img src="/assets/root.png" alt="root">' +
|
||||
'<img src="//cdn.example.com/p.png" alt="protorel">' +
|
||||
'<a href="%E5%9B%BE%E7%89%87/%E6%88%AA%E5%9B%BE.md" target="_blank" rel="noopener noreferrer">cjk</a>' +
|
||||
'<a href="/docs/root.md" target="_blank" rel="noopener noreferrer">rootlink</a>';
|
||||
|
||||
const MD_CONTENT = '# Title\n\nx\n';
|
||||
const TXT_CONTENT = 'one\n\n three\tfour\n';
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
||||
}
|
||||
|
||||
/** Answer file-content like the route does: text as JSON, an image as metadata. */
|
||||
function fetchStub(url: string) {
|
||||
// An attachment's by-id raw route answers the bytes themselves.
|
||||
if (url.includes('/attachments/')) return { ok: true, status: 200, text: async () => MD_CONTENT };
|
||||
const path = decodeURIComponent(new URL(url, 'http://x').searchParams.get('path') || '');
|
||||
const ext = path.split('.').pop() || '';
|
||||
if (ext === 'png') {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { type: 'image', url: `/file-raw?path=${path}`, size: 5, extension: ext },
|
||||
});
|
||||
}
|
||||
const content = ext === 'md' ? MD_CONTENT : TXT_CONTENT;
|
||||
if (url.includes('edit=1')) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { content, hash: 'h', eol: 'lf', totalLines: 3, size: content.length },
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { path, content, totalLines: 3, size: content.length, truncated: false, extension: ext, editable: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** The file-preview overlay's elements, as index.html ships them. */
|
||||
function mountPreviewDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
|
||||
<button id="filePreviewMdBtn" hidden></button>
|
||||
<button id="filePreviewLinesBtn" hidden></button>
|
||||
<button id="filePreviewWrapBtn" hidden></button>
|
||||
<button id="filePreviewEditBtn" hidden></button>
|
||||
<button id="filePreviewDetachBtn" hidden></button>
|
||||
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
|
||||
}
|
||||
|
||||
function loadApp(prefs: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(prefs));
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const fetchMock = vi.fn(async (url: string) => fetchStub(url));
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||||
localStorage: {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
document,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
|
||||
MobileDetection: {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
confirm: () => true,
|
||||
fetch: fetchMock,
|
||||
});
|
||||
vm.runInContext(`${constantsJs}\n${panelsJs}\nglobalThis.__exts = FILE_PREVIEW_EXTENSIONS;`, context, {
|
||||
filename: 'panels-ui.js',
|
||||
});
|
||||
|
||||
mountPreviewDom();
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app._resetFilePreviewEdit = () => {};
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app.formatFileSize = (n: number) => `${n} B`;
|
||||
app.showToast = vi.fn();
|
||||
app.filePreviewContent = '';
|
||||
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
|
||||
app._linkifyFilePaths = vi.fn();
|
||||
app._bindResponseViewerInteractions = vi.fn();
|
||||
|
||||
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
|
||||
return {
|
||||
app,
|
||||
fetchMock,
|
||||
store,
|
||||
body: byId('filePreviewBody'),
|
||||
exts: (context as { __exts: Set<string> }).__exts,
|
||||
btn: { md: byId('filePreviewMdBtn'), lines: byId('filePreviewLinesBtn'), wrap: byId('filePreviewWrapBtn') },
|
||||
};
|
||||
}
|
||||
|
||||
describe('file viewer rendered markdown', () => {
|
||||
it('renders .md through the shared markdown pipeline, inert to i18n, with the viewer delegate bound', async () => {
|
||||
const { app, body, btn } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
const doc = body.firstElementChild as HTMLElement;
|
||||
expect(doc.matches('.rv-text.file-preview-md[data-i18n-skip]')).toBe(true);
|
||||
expect(doc.querySelector('h1')?.textContent).toBe('Title');
|
||||
// A file, not a chat message: source newlines inside a paragraph are not breaks.
|
||||
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
|
||||
// Identity, not deep equality: DOM nodes are compared by reference here.
|
||||
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
|
||||
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
|
||||
// The source stays what Copy copies.
|
||||
expect(app.filePreviewContent).toBe(MD_CONTENT);
|
||||
// MD is the only toggle that applies to a rendered document; Edit still offered.
|
||||
expect(btn.md.hidden).toBe(false);
|
||||
expect(btn.md.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(btn.lines.hidden).toBe(true);
|
||||
expect(btn.wrap.hidden).toBe(true);
|
||||
expect(document.getElementById('filePreviewEditBtn')!.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches the route ceiling for markdown and the 500-line cap for other text', async () => {
|
||||
const { app, fetchMock } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
await app.openFilePreview('notes.txt', 's1');
|
||||
|
||||
const urls = fetchMock.mock.calls.map((c) => c[0]);
|
||||
expect(urls[0]).toContain('lines=10000');
|
||||
expect(urls[1]).toContain('lines=500');
|
||||
});
|
||||
|
||||
it('rebases relative images and links onto the document directory and leaves the rest alone', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
const local = body.querySelector('img[alt="Alt A"]')!;
|
||||
expect(local.getAttribute('src')).toBe(`/api/sessions/s1/file-raw?path=${encodeURIComponent('docs/img/a.png')}`);
|
||||
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
|
||||
|
||||
const rel = body.querySelector('a.rv-path')!;
|
||||
expect(rel.getAttribute('data-path')).toBe('docs/guide/x.md');
|
||||
expect(rel.getAttribute('href')).toBe('#');
|
||||
expect(rel.hasAttribute('target')).toBe(false);
|
||||
expect(rel.hasAttribute('rel')).toBe(false);
|
||||
|
||||
const anchors = Array.from(body.querySelectorAll('a'));
|
||||
// `..` is collapsed against the document directory, so the title reads
|
||||
// CHANGELOG.md rather than docs/../CHANGELOG.md.
|
||||
const up = anchors.find((a) => a.textContent === 'up')!;
|
||||
expect(up.classList.contains('rv-path')).toBe(true);
|
||||
expect(up.getAttribute('data-path')).toBe('CHANGELOG.md');
|
||||
const fragment = anchors.find((a) => a.textContent === 't')!;
|
||||
expect(fragment.getAttribute('href')).toBe('#top');
|
||||
expect(fragment.classList.contains('rv-path')).toBe(false);
|
||||
const external = anchors.find((a) => a.textContent === 'e')!;
|
||||
expect(external.getAttribute('href')).toBe('https://e.com');
|
||||
expect(external.getAttribute('target')).toBe('_blank');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
|
||||
const { app, body } = loadApp();
|
||||
// An absolute path in the document's prose, linked by the Response
|
||||
// Viewer's linkifier, which knows nothing of the preview's session.
|
||||
app._linkifyFilePaths.mockImplementation((root: HTMLElement) => {
|
||||
const a = root.ownerDocument.createElement('a');
|
||||
a.className = 'rv-path';
|
||||
a.dataset.path = '/tmp/out/run.log';
|
||||
root.appendChild(a);
|
||||
});
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
|
||||
// Decoded once here, encoded once for the route: never `my%2520image.png`.
|
||||
expect(src('space')).toBe(raw('docs/my image.png'));
|
||||
expect(src('raw')).toBe(raw('docs/raw.png'));
|
||||
// A malformed escape keeps the ref as written.
|
||||
expect(src('bad')).toBe(raw('docs/bad%zz.png'));
|
||||
// Root-relative is the workspace root, as on GitHub; protocol-relative is remote.
|
||||
expect(src('root')).toBe(raw('assets/root.png'));
|
||||
expect(src('protorel')).toBe('//cdn.example.com/p.png');
|
||||
|
||||
const anchors = Array.from(body.querySelectorAll('a'));
|
||||
expect(anchors.find((a) => a.textContent === 'cjk')!.getAttribute('data-path')).toBe('docs/图片/截图.md');
|
||||
expect(anchors.find((a) => a.textContent === 'rootlink')!.getAttribute('data-path')).toBe('docs/root.md');
|
||||
// Every rebased link, and every path the linkifier found in the prose,
|
||||
// names the preview's session, so the delegate opens it in that workspace
|
||||
// even when another tab is active.
|
||||
const rebased = body.querySelectorAll('a.rv-path');
|
||||
expect(rebased.length).toBe(5);
|
||||
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('degrades relative refs of an attachment opened by bare file name instead of resolving them in the workspace', async () => {
|
||||
const { app, body, fetchMock } = loadApp();
|
||||
|
||||
// An attachment card passes the registry's bare file name: the document's
|
||||
// directory is unknown, so `img/a.png` must not become the workspace root's.
|
||||
await app.openFilePreview('report.md', 's1', 'att-1');
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('/attachments/att-1/raw');
|
||||
expect(body.innerHTML).not.toContain('file-raw');
|
||||
// Relative and root-relative images are their alt text, as a text node.
|
||||
for (const alt of ['Alt A', 'space', 'raw', 'bad', 'root']) {
|
||||
expect(body.querySelector(`img[alt="${alt}"]`)).toBeNull();
|
||||
expect(body.textContent).toContain(alt);
|
||||
}
|
||||
// Remote images and links keep today's handling.
|
||||
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
|
||||
expect(body.querySelector('img[alt="protorel"]')!.getAttribute('src')).toBe('//cdn.example.com/p.png');
|
||||
// Relative links are unwrapped to their text; fragment and http(s) links stay.
|
||||
expect(body.querySelectorAll('a.rv-path')).toHaveLength(0);
|
||||
const anchors = Array.from(body.querySelectorAll('a')).map((a) => a.textContent);
|
||||
expect(anchors).toEqual(['t', 'e']);
|
||||
for (const text of ['x', 'up', 'cjk', 'rootlink']) expect(body.textContent).toContain(text);
|
||||
});
|
||||
|
||||
it('keeps resolving refs of an absolute-path attachment against its own directory', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('/tmp/out/report.md', 's1', 'att-2');
|
||||
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
expect(body.querySelector('img[alt="Alt A"]')!.getAttribute('src')).toBe(raw('/tmp/out/img/a.png'));
|
||||
const rel = Array.from(body.querySelectorAll('a.rv-path')).find((a) => a.textContent === 'x')!;
|
||||
expect(rel.getAttribute('data-path')).toBe('/tmp/out/guide/x.md');
|
||||
expect(rel.getAttribute('data-session-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('degrades an image that fails to load to its alt text', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const remote = body.querySelector('img[alt="remote"]')!;
|
||||
remote.dispatchEvent(new dom.window.Event('error'));
|
||||
|
||||
expect(body.querySelector('img[alt="remote"]')).toBeNull();
|
||||
expect(body.textContent).toContain('remote');
|
||||
});
|
||||
|
||||
it('MD toggle flips to per-line source and back without refetching, and persists', async () => {
|
||||
const { app, body, btn, fetchMock, store } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
app.toggleFilePreviewMd();
|
||||
|
||||
const pre = body.firstElementChild as HTMLElement;
|
||||
expect(pre.matches('pre.file-preview-text')).toBe(true);
|
||||
expect(pre.querySelectorAll('.fp-line')).toHaveLength(MD_CONTENT.split('\n').length);
|
||||
expect(pre.textContent).toBe(MD_CONTENT);
|
||||
expect(store.get('codeman:filePreviewMdRendered')).toBe('0');
|
||||
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(btn.lines.hidden).toBe(false);
|
||||
expect(btn.wrap.hidden).toBe(false);
|
||||
|
||||
app.toggleFilePreviewMd();
|
||||
expect((body.firstElementChild as HTMLElement).matches('.file-preview-md')).toBe(true);
|
||||
expect(store.get('codeman:filePreviewMdRendered')).toBe('1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens as source when the device pref says so', async () => {
|
||||
const { app, body, btn } = loadApp({ 'codeman:filePreviewMdRendered': '0' });
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
expect((body.firstElementChild as HTMLElement).matches('pre.file-preview-text')).toBe(true);
|
||||
expect(btn.md.hidden).toBe(false);
|
||||
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file viewer Lines and Wrap toggles', () => {
|
||||
it('flip classes on the <pre>, persist, and never alter the text', async () => {
|
||||
const { app, body, btn, store } = loadApp();
|
||||
|
||||
await app.openFilePreview('notes.txt', 's1');
|
||||
|
||||
const pre = body.firstElementChild as HTMLElement;
|
||||
expect(pre.matches('pre.file-preview-text.wrap:not(.show-lines)')).toBe(true);
|
||||
expect(pre.textContent).toBe(TXT_CONTENT);
|
||||
expect(btn.md.hidden).toBe(true);
|
||||
|
||||
app.toggleFilePreviewLines();
|
||||
expect(pre.classList.contains('show-lines')).toBe(true);
|
||||
expect(store.get('codeman:filePreviewLineNumbers')).toBe('1');
|
||||
expect(btn.lines.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
app.toggleFilePreviewWrap();
|
||||
expect(pre.classList.contains('wrap')).toBe(false);
|
||||
expect(store.get('codeman:filePreviewWrap')).toBe('0');
|
||||
expect(btn.wrap.getAttribute('aria-pressed')).toBe('false');
|
||||
// Same element, no re-render: the counter gutter is CSS, not text.
|
||||
expect(body.firstElementChild).toBe(pre);
|
||||
expect(pre.textContent).toBe(TXT_CONTENT);
|
||||
});
|
||||
|
||||
it('are hidden for an image and while editing', async () => {
|
||||
const { app, btn, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('shot.png', 's1');
|
||||
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
|
||||
|
||||
await app.openFilePreview('notes.txt', 's1');
|
||||
expect(btn.lines.hidden).toBe(false);
|
||||
await app.enterFilePreviewEdit();
|
||||
expect(body.querySelector('textarea.file-preview-editor')).not.toBeNull();
|
||||
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** A vendored UMD build (or sanitize-html.js), evaluated as CommonJS the way the other suites do. */
|
||||
function loadCommonJs<T>(name: string): T {
|
||||
const module: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile(name))(module, module.exports);
|
||||
return module.exports as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHIPPING pipeline end to end: app.js (`_renderMarkdown` and the Response
|
||||
* Viewer's message builder) with panels-ui.js mixed in, the vendored marked,
|
||||
* and DOMPurify behind the real sanitize-html.js config. `content` is what the
|
||||
* file-content route answers for every path.
|
||||
*/
|
||||
function loadShippingApp(content: string) {
|
||||
const createDOMPurify = loadCommonJs<(win: unknown) => unknown>('vendor/dompurify.min.js');
|
||||
const { createMarkdownSanitizer } = loadCommonJs<{ createMarkdownSanitizer: (dp: unknown) => unknown }>(
|
||||
'sanitize-html.js'
|
||||
);
|
||||
const context = vm.createContext({
|
||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
document,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() },
|
||||
// _sanitizeHtml fails closed without the page's sanitizer, which would make
|
||||
// every assertion below vacuous.
|
||||
window: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
sanitizeMarkdownHtml: createMarkdownSanitizer(createDOMPurify(dom.window)),
|
||||
},
|
||||
marked: loadCommonJs('vendor/marked.min.js'),
|
||||
MobileDetection: {},
|
||||
confirm: () => true,
|
||||
fetch: vi.fn(async () =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: { content, totalLines: 2, size: content.length, truncated: false, extension: 'md' },
|
||||
})
|
||||
),
|
||||
});
|
||||
vm.runInContext(
|
||||
`${constantsJs}\n${publicFile('app.js')}\n${panelsJs}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context,
|
||||
{ filename: 'app.js' }
|
||||
);
|
||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
|
||||
mountPreviewDom();
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map();
|
||||
app.filePreviewContent = '';
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('file viewer markdown line breaks', () => {
|
||||
// A README hard-wrapped at the column limit: one paragraph in the source.
|
||||
const WRAPPED = 'A paragraph hard-wrapped\nat the column limit.';
|
||||
|
||||
it('renders a hard-wrapped paragraph as one paragraph in the file view, while chat keeps a break per newline', async () => {
|
||||
const app = loadShippingApp(`${WRAPPED}\n`);
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const para = document.querySelector('#filePreviewBody .file-preview-md p')!;
|
||||
expect(para, 'the document rendered through marked').not.toBeNull();
|
||||
expect(para.querySelector('br')).toBeNull();
|
||||
expect(para.textContent).toBe(WRAPPED);
|
||||
|
||||
// The Response Viewer renders the same text the chat way, a <br> per newline.
|
||||
const message = app._buildResponseViewerMessage(WRAPPED, 'assistant', 'Claude') as HTMLElement;
|
||||
const chatPara = message.querySelector('.rv-text p')!;
|
||||
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
|
||||
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('FILE_PREVIEW_EXTENSIONS', () => {
|
||||
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
|
||||
const { exts } = loadApp();
|
||||
expect(exts.has('avif')).toBe(true);
|
||||
expect(exts.has('ico')).toBe(true);
|
||||
expect(exts.has('md')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* hook definitions for desktop notifications.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
|
||||
import {
|
||||
applyStatusLineConfig,
|
||||
ensureCodemanHooks,
|
||||
ensureStatusLineExporterScript,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
|
||||
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
|
||||
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
|
||||
// A frozen clock makes every writer agree on the timestamp, which is what two
|
||||
// session creates in one millisecond do. A temp name built from pid + Date.now()
|
||||
// is then shared: the first rename consumes it, every later rename fails ENOENT,
|
||||
// and that session's tmux create fell back to a direct PTY.
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
try {
|
||||
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
|
||||
expect(new Set(paths)).toEqual(new Set([scriptPath]));
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
|
||||
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
|
||||
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('never overrides a real, hand-authored statusLine', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/** @fileoverview Settings → Terminal & Input → Key tester, driven with real keystrokes in Chromium. */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3194;
|
||||
|
||||
describe('Key tester in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.focus('#keyTesterInput');
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
const log = () => page.evaluate(() => document.getElementById('keyTesterLog')!.textContent ?? '');
|
||||
|
||||
it('shows keydown, keypress and keyup for Shift+Enter, with the modifier and charCode', async () => {
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
const text = await log();
|
||||
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=shift/);
|
||||
// The keypress is the event that used to leak a bare \r to the PTY.
|
||||
expect(text).toMatch(/keypress\s+key="Enter" code=Enter mods=shift charCode=13/);
|
||||
expect(text).toMatch(/keyup\s+key="Enter" code=Enter mods=shift/);
|
||||
});
|
||||
|
||||
it('shows Ctrl+Enter without a keypress, as xterm would never see one for Ctrl', async () => {
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
await page.keyboard.press('Control+Enter');
|
||||
const text = await log();
|
||||
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=ctrl/);
|
||||
expect(text).toMatch(/keyup/);
|
||||
// Chromium emits no keypress for a Ctrl chord, which is why only Shift+Enter ever leaked a \r.
|
||||
expect(text).not.toMatch(/keypress/);
|
||||
});
|
||||
|
||||
it('lets no app shortcut fire for keys pressed in the field (Ctrl+W, Ctrl+L, Escape, Alt+1, Ctrl+K)', async () => {
|
||||
// The shortcut dispatcher is a capture-phase document listener, so without a guard it ran before
|
||||
// the field's own handler: Ctrl+W killed the active session, Ctrl+L cleared the terminal and
|
||||
// Escape closed Settings, while this row says nothing is sent to a session.
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
const calls: string[] = [];
|
||||
(window as any).__calls = calls;
|
||||
for (const name of ['killActiveSession', 'clearTerminal', 'openCommandPalette', 'closeAllPanels']) {
|
||||
app[name] = (...args: unknown[]) => void calls.push(name + args.length);
|
||||
}
|
||||
});
|
||||
await page.focus('#keyTesterInput');
|
||||
// [chord, what the tester must report for it]; checked one at a time because the log keeps 14 lines.
|
||||
const chords: [string, RegExp][] = [
|
||||
['Control+W', /key="w" code=KeyW mods=ctrl/i],
|
||||
['Control+L', /key="l" code=KeyL mods=ctrl/i],
|
||||
['Escape', /key="Escape" code=Escape/],
|
||||
['Alt+1', /code=Digit1 mods=alt/],
|
||||
['Control+K', /key="k" code=KeyK mods=ctrl/i],
|
||||
];
|
||||
for (const [chord, seen] of chords) {
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
await page.keyboard.press(chord);
|
||||
expect(await log(), chord).toMatch(seen);
|
||||
expect(await page.evaluate(() => (window as any).__calls), chord).toEqual([]);
|
||||
}
|
||||
expect(await page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('still lets the shortcut fire anywhere else (the guard is scoped to data-raw-keys)', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__calls.length = 0;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(() => (window as any).__calls)).toContain('closeAllPanels0');
|
||||
});
|
||||
|
||||
it('keeps only the last 14 lines and never types into the field', async () => {
|
||||
// The test above blurred the field, so focus it again: without this the presses land on
|
||||
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
|
||||
await page.focus('#keyTesterInput');
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
const lines = async () => (await log()).split('\n');
|
||||
|
||||
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
|
||||
// the log accumulates rather than showing only the latest event.
|
||||
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
|
||||
expect(await lines()).toHaveLength(6);
|
||||
|
||||
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
|
||||
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
|
||||
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
|
||||
const capped = await lines();
|
||||
expect(capped).toHaveLength(14);
|
||||
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
|
||||
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
|
||||
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
|
||||
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
|
||||
|
||||
// Readonly: none of those presses typed anything into the field itself.
|
||||
expect(await page.inputValue('#keyTesterInput')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,16 @@ describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
|
||||
expect(sanitize(html).toLowerCase()).not.toContain(tag);
|
||||
});
|
||||
}
|
||||
|
||||
// DOM clobbering: <img name="app"> makes document.app that image, and inline
|
||||
// onclick="app.…()" handlers resolve `app` on the document before the global,
|
||||
// so a rendered README could break every button until a reload.
|
||||
it('drops name= (marked never emits it; it clobbers document.<name>)', () => {
|
||||
const out = sanitize('<img name="app" src="https://example.com/x.png" alt="x"><a name="app" href="#a">a</a>');
|
||||
expect(out).not.toMatch(/\sname\s*=/i);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('href="#a"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legitimate markdown-rendered HTML survives', () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
// The registry half of MCP sync: which CLIs declare an MCP config file, and that the schema
|
||||
// guards the path (sync writes to it) so a user clis.json cannot aim a write outside $HOME.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
function withMcp(mcpConfig: unknown) {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).mcpConfig = mcpConfig;
|
||||
return CliEntrySchema.safeParse(e);
|
||||
}
|
||||
|
||||
describe('capabilities.mcpConfig', () => {
|
||||
it('is declared by exactly the CLIs whose format is verified', () => {
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
|
||||
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
|
||||
});
|
||||
|
||||
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
|
||||
for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true);
|
||||
const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : []));
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
|
||||
it('accepts a home-relative path with a known format', () => {
|
||||
expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("declares each CLI's own relocation env var, and none for antigravity (HOME only)", () => {
|
||||
const reloc = Object.fromEntries(
|
||||
STOCK_CLIS.flatMap((e) =>
|
||||
e.capabilities.mcpConfig ? [[e.id as string, e.capabilities.mcpConfig.relocation]] : []
|
||||
)
|
||||
);
|
||||
expect(reloc).toEqual({
|
||||
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||
antigravity: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a relocation with an env var name and a relative path', () => {
|
||||
const value = { path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: 'mcp.json' } };
|
||||
expect(withMcp(value).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['parent traversal', { path: '../evil.json', format: 'claude-json' }],
|
||||
['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }],
|
||||
['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }],
|
||||
['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }],
|
||||
['unknown format', { path: '.a/mcp.json', format: 'yaml' }],
|
||||
['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }],
|
||||
[
|
||||
'relocation path traversal',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '../x.json' } },
|
||||
],
|
||||
[
|
||||
'relocation absolute path',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '/etc/x.json' } },
|
||||
],
|
||||
[
|
||||
'relocation env var that is not a name',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'a-home', path: 'x.json' } },
|
||||
],
|
||||
])('rejects %s', (_label, value) => {
|
||||
expect(withMcp(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
// @vitest-environment node
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
|
||||
|
||||
const TARGETS: McpSyncTarget[] = [
|
||||
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
|
||||
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
|
||||
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
path: '.gemini/config/mcp_config.json',
|
||||
format: 'antigravity-json',
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
path: '.config/opencode/opencode.json',
|
||||
format: 'opencode-json',
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
|
||||
let home: string;
|
||||
const put = (rel: string, text: string) => {
|
||||
const file = join(home, rel);
|
||||
mkdirSync(join(file, '..'), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
};
|
||||
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
|
||||
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
|
||||
...TARGETS.find((t) => t.id === id)!,
|
||||
...patch,
|
||||
});
|
||||
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
|
||||
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
describe('dialect parsing', () => {
|
||||
it('reads codex TOML tables, inline tables and multi-line arrays', () => {
|
||||
const servers = parseServers(
|
||||
'codex-toml',
|
||||
[
|
||||
'model = "gpt-5"',
|
||||
'',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "npx"',
|
||||
'args = [',
|
||||
' "-y", # comment',
|
||||
' "@mcp/fs",',
|
||||
']',
|
||||
'env = { TOKEN = "abc" }',
|
||||
'',
|
||||
'[mcp_servers."a.b".env]',
|
||||
'K = "v"',
|
||||
'',
|
||||
'[mcp_servers."a.b"]',
|
||||
'command = "x"',
|
||||
'',
|
||||
'[mcp_servers.web]',
|
||||
'url = "https://x.test/mcp"',
|
||||
'[mcp_servers.web.http_headers]',
|
||||
'Authorization = "Bearer t"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } });
|
||||
expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://x.test/mcp',
|
||||
headers: { Authorization: 'Bearer t' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
|
||||
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
|
||||
});
|
||||
|
||||
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
|
||||
const g = parseServers(
|
||||
'gemini-json',
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
a: { url: 'https://a' },
|
||||
b: { httpUrl: 'https://b' },
|
||||
c: { command: 'c', args: ['1'] },
|
||||
d: { url: 'https://d', type: 'http' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(g.a.transport).toBe('sse');
|
||||
expect(g.b.transport).toBe('http');
|
||||
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
|
||||
expect(g.d.transport).toBe('http');
|
||||
const o = parseServers(
|
||||
'opencode-json',
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } },
|
||||
r: { type: 'remote', url: 'https://r' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } });
|
||||
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
|
||||
});
|
||||
|
||||
it('throws on unparseable files so they are never written', () => {
|
||||
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
|
||||
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => {
|
||||
it('reads and writes the antigravity dialect', () => {
|
||||
const real = JSON.stringify({
|
||||
mcpServers: {
|
||||
fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } },
|
||||
web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' },
|
||||
},
|
||||
});
|
||||
const servers = parseServers('antigravity-json', real);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://x.test/mcp',
|
||||
headers: { Authorization: 'Bearer T' },
|
||||
});
|
||||
const out = JSON.parse(
|
||||
addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } })
|
||||
);
|
||||
expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp');
|
||||
expect(out.mcpServers.fs.disabled).toBe(false);
|
||||
expect(out.mcpServers.s).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('gemini-json', null, {
|
||||
web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } },
|
||||
s: { transport: 'sse', url: 'https://x.test/sse' },
|
||||
})
|
||||
);
|
||||
expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } });
|
||||
expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' });
|
||||
expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http');
|
||||
});
|
||||
|
||||
it('reads codex output as written by `codex mcp add`', () => {
|
||||
const real =
|
||||
'[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n';
|
||||
const servers = parseServers('codex-toml', real);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostile config files', () => {
|
||||
it('never lets a server name or a sub-table key reach Object.prototype', () => {
|
||||
const toml = parseServers(
|
||||
'codex-toml',
|
||||
[
|
||||
'[mcp_servers.__proto__]',
|
||||
'command = "x"',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs.__proto__]',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "y"',
|
||||
'[mcp_servers.toString]',
|
||||
'command = "t"',
|
||||
'call = "x"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
|
||||
const json = parseServers(
|
||||
'claude-json',
|
||||
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
|
||||
);
|
||||
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(({} as Record<string, unknown>).command).toBeUndefined();
|
||||
expect(typeof Object.prototype.toString.call).toBe('function');
|
||||
});
|
||||
|
||||
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(r.conflicts).toEqual([]);
|
||||
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
|
||||
'hasOwnProperty',
|
||||
'toString',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a non-object server table instead of overwriting it', () => {
|
||||
expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow();
|
||||
expect(() => parseServers('claude-json', '[]')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addServers', () => {
|
||||
it('preserves other keys and existing servers', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
|
||||
keep: { transport: 'stdio', command: 'OVERWRITE' },
|
||||
n: { transport: 'stdio', command: 'n' },
|
||||
})
|
||||
);
|
||||
expect(out.theme).toBe('dark');
|
||||
expect(out.mcpServers.keep).toEqual({ command: 'k' });
|
||||
expect(out.mcpServers.n.command).toBe('n');
|
||||
});
|
||||
|
||||
it('appends codex tables without touching the rest, and quotes odd names', () => {
|
||||
const toml = addServers('codex-toml', 'model = "x"\n', {
|
||||
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
|
||||
});
|
||||
expect(toml.startsWith('model = "x"\n')).toBe(true);
|
||||
expect(parseServers('codex-toml', toml)['we ird']).toEqual({
|
||||
transport: 'stdio',
|
||||
command: 'c',
|
||||
args: ['a"b'],
|
||||
env: { K: 'v' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
|
||||
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
|
||||
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
|
||||
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
|
||||
const out = addServers('codex-toml', existing, {
|
||||
fs: { transport: 'stdio', command: 'other' },
|
||||
extra: { transport: 'stdio', command: 'e' },
|
||||
});
|
||||
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
|
||||
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
|
||||
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
|
||||
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
|
||||
expect(out.startsWith(existing)).toBe(true);
|
||||
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('refuses to write when the result would not read back as intended', () => {
|
||||
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
|
||||
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled servers are not propagated', () => {
|
||||
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
|
||||
put(
|
||||
'.config/opencode/opencode.json',
|
||||
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
|
||||
);
|
||||
put(
|
||||
'.gemini/config/mcp_config.json',
|
||||
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
|
||||
);
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
|
||||
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
|
||||
// ...and each CLI still gets the live one.
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
|
||||
expect(get('.codex/config.toml')).not.toContain('off_oc');
|
||||
// The switched-off entry itself is left as it was (still disabled).
|
||||
expect(get('.codex/config.toml')).toContain('enabled = false');
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
|
||||
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
|
||||
expect(r.disabled).toEqual([]);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncMcpServers', () => {
|
||||
const claudeFile = JSON.stringify({
|
||||
numStartups: 3,
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
|
||||
});
|
||||
const setUpAll = () => {
|
||||
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
put('.codex/config.toml', '');
|
||||
put('.config/opencode/opencode.json', '{}');
|
||||
put('.gemini/config/mcp_config.json', '{}');
|
||||
};
|
||||
|
||||
it('previews without writing and never leaks env values', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
const before = get('.gemini/settings.json');
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
||||
expect(r.applied).toBe(false);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
expect(get('.gemini/settings.json')).toBe(before);
|
||||
expect(JSON.stringify(r)).not.toContain('s3cret');
|
||||
});
|
||||
|
||||
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(result(r, 'claude').added).toEqual(['web']);
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
|
||||
expect(get('.codex/config.toml')).toContain('model = "gpt-5"');
|
||||
expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true);
|
||||
|
||||
const again = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
|
||||
setUpAll();
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } }));
|
||||
const broken = '{ // jsonc\n "mcp": {} }';
|
||||
put('.config/opencode/opencode.json', broken);
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.conflicts).toEqual(['x']);
|
||||
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
|
||||
expect(result(r, 'codex').skipped).toEqual(['sse']);
|
||||
expect(result(r, 'opencode').status).toBe('unreadable');
|
||||
expect(get('.config/opencode/opencode.json')).toBe(broken);
|
||||
});
|
||||
|
||||
it('passes the unsupported list through to the result', async () => {
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
|
||||
expect(r.unsupported).toEqual(['Pi']);
|
||||
});
|
||||
|
||||
describe('only CLIs that are installed or already have a config file take part', () => {
|
||||
it('never creates config for a CLI that is neither installed nor configured', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
|
||||
const r = await syncMcpServers(notInstalled, { apply: true, home });
|
||||
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(home, '.gemini'))).toBe(false);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
|
||||
it('an installed CLI with no config yet gets one created', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file safety', () => {
|
||||
it('leaves a file that receives env values or headers readable by its owner only', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('keeps the existing mode when nothing secret is copied', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
|
||||
});
|
||||
|
||||
it('writes through a symlinked config instead of replacing the link', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, 'dotfiles'), { recursive: true });
|
||||
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
|
||||
'npx'
|
||||
);
|
||||
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a dangling symlink as failed and writes nothing', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(result(r, 'gemini').status).toBe('failed');
|
||||
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
|
||||
McpSyncBusyError
|
||||
);
|
||||
await first;
|
||||
// A preview is read-only and is never refused.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
|
||||
// ...and the lock is released afterwards.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
|
||||
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages never quote the config file (it holds env values and headers)', () => {
|
||||
const claudeWithSecret = JSON.stringify({
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
|
||||
});
|
||||
|
||||
it('reports an unparseable TOML file by line and column only', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
put(
|
||||
'.codex/config.toml',
|
||||
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
|
||||
);
|
||||
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
|
||||
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
|
||||
});
|
||||
|
||||
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
|
||||
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
|
||||
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
|
||||
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
|
||||
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
|
||||
expect(result(r, 'gemini').status).toBe('unreadable');
|
||||
expect(result(r, 'gemini').error).toBe('not valid JSON');
|
||||
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
|
||||
const body = JSON.stringify(r);
|
||||
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
|
||||
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
|
||||
put('.codex/config.toml', inline);
|
||||
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('failed');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
|
||||
expect(get('.codex/config.toml')).toBe(inline);
|
||||
const body = JSON.stringify(r);
|
||||
expect(body).not.toContain('sk-FILE-SECRET');
|
||||
expect(body).not.toContain('sk-COPIED-SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
|
||||
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
|
||||
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
|
||||
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
|
||||
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
|
||||
|
||||
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const codexHome = join(home, 'elsewhere/codex');
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
});
|
||||
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
|
||||
const claudeDir = join(home, 'accounts/work');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
|
||||
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
|
||||
const xdg = join(home, 'xdg');
|
||||
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
|
||||
});
|
||||
expect(result(r, 'claude').servers).toEqual(['fs']);
|
||||
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: 'relative/codex' },
|
||||
});
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
|
||||
expect(codex.added).toEqual([]);
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an empty value means unset, as it does for the CLI', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: '' },
|
||||
});
|
||||
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
|
||||
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const saved = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = join(home, 'from-process-env');
|
||||
try {
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
|
||||
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
||||
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
|
||||
*
|
||||
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
|
||||
* pin how terminal-ui.js and the delivery graph consume it: script order,
|
||||
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
|
||||
* the onData routing of an IME commit, and the rule that only output accepted
|
||||
* AFTER a commit may clear the committed preview.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const indexSource = read('src/web/public/index.html');
|
||||
const buildSource = read('scripts/build.mjs');
|
||||
const terminalSource = read('src/web/public/terminal-ui.js');
|
||||
const cssSource = read('src/web/public/styles.css');
|
||||
|
||||
type Fn = ReturnType<typeof vi.fn>;
|
||||
type App = Record<string, any>;
|
||||
|
||||
function loadMixin(globals: Record<string, unknown> = {}) {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const windowStub = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
...(globals.window as object),
|
||||
} as Record<string, unknown>;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
URLSearchParams,
|
||||
location: { search: '' },
|
||||
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
document: { addEventListener: vi.fn(), createElement: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
CodemanApp: FakeCodemanApp,
|
||||
_crashDiag: { log: vi.fn() },
|
||||
...globals,
|
||||
window: windowStub,
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, context, windowStub };
|
||||
}
|
||||
|
||||
function fakeClassList() {
|
||||
const values = new Set<string>();
|
||||
return {
|
||||
add: vi.fn((value: string) => values.add(value)),
|
||||
remove: vi.fn((value: string) => values.delete(value)),
|
||||
toggle: vi.fn((value: string, force?: boolean) => {
|
||||
const enabled = force === undefined ? !values.has(value) : force;
|
||||
if (enabled) values.add(value);
|
||||
else values.delete(value);
|
||||
return enabled;
|
||||
}),
|
||||
contains: (value: string) => values.has(value),
|
||||
};
|
||||
}
|
||||
|
||||
function createPreviewHarness(
|
||||
options: {
|
||||
eligible?: boolean;
|
||||
createThrows?: boolean;
|
||||
omitGlobal?: boolean;
|
||||
themeForeground?: string;
|
||||
themeBackground?: string;
|
||||
themeGetterThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const compositionView = {
|
||||
style: {
|
||||
fontFamily: '"Fira Code"',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: '12px',
|
||||
height: '12px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
},
|
||||
};
|
||||
const helpers = {
|
||||
classList: fakeClassList(),
|
||||
children: [] as Array<Record<string, unknown>>,
|
||||
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
|
||||
appendChild(node: Record<string, unknown>) {
|
||||
this.children.push(node);
|
||||
},
|
||||
};
|
||||
const createdControllers: Array<Record<string, any>> = [];
|
||||
const previewNodes: Array<Record<string, any>> = [];
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement: vi.fn(() => {
|
||||
const node = {
|
||||
className: '',
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
dataset: {} as Record<string, string>,
|
||||
style: {} as Record<string, string>,
|
||||
attributes: {} as Record<string, string>,
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes[name] = value;
|
||||
},
|
||||
remove: vi.fn(),
|
||||
};
|
||||
previewNodes.push(node);
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
const mobileImePreview = options.omitGlobal
|
||||
? undefined
|
||||
: {
|
||||
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
|
||||
create: vi.fn((callbacks: Record<string, unknown>) => {
|
||||
if (options.createThrows) throw new Error('controller unavailable');
|
||||
const controller = {
|
||||
destroy: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
consumeTerminalData: vi.fn(() => false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
callbacks,
|
||||
};
|
||||
createdControllers.push(controller);
|
||||
return controller;
|
||||
}),
|
||||
};
|
||||
const { mixin, windowStub } = loadMixin({
|
||||
document: documentStub,
|
||||
MobileImePreview: mobileImePreview,
|
||||
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
|
||||
});
|
||||
windowStub.codemanCurrentXtermTheme = () => {
|
||||
if (options.themeGetterThrows) throw new Error('theme unavailable');
|
||||
return { foreground: '#334455', background: '#223344' };
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
options: {
|
||||
theme:
|
||||
options.themeForeground || options.themeBackground
|
||||
? { foreground: options.themeForeground, background: options.themeBackground }
|
||||
: undefined,
|
||||
},
|
||||
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
});
|
||||
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
|
||||
}
|
||||
|
||||
describe('mobile IME preview delivery graph', () => {
|
||||
it('loads the controller after xterm and before terminal wiring', () => {
|
||||
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
|
||||
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
|
||||
});
|
||||
|
||||
it('is minified and content-hashed by the build', () => {
|
||||
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
|
||||
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
|
||||
});
|
||||
|
||||
it('scopes preview presentation and native composition suppression to touch ownership', () => {
|
||||
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
|
||||
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
|
||||
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
|
||||
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
|
||||
});
|
||||
|
||||
it('initializes the preview right after the terminal opens', () => {
|
||||
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME preview lifecycle', () => {
|
||||
it('fails open when the global is absent or create throws', () => {
|
||||
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
|
||||
const { app } = createPreviewHarness(options);
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a controller on unsupported input platforms', () => {
|
||||
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).not.toHaveBeenCalled();
|
||||
expect(previewNodes).toHaveLength(0);
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
});
|
||||
|
||||
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
|
||||
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
|
||||
// Keydown is observed on the terminal element (an ancestor of the
|
||||
// textarea), so it runs before xterm's own capture listener finalizes.
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
|
||||
expect(helpers.children).toEqual([previewNodes[0]]);
|
||||
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
|
||||
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
|
||||
});
|
||||
|
||||
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const first = createdControllers[0];
|
||||
app._initMobileImePreview();
|
||||
expect(first.destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(createdControllers).toHaveLength(2);
|
||||
expect(app._mobileImePreview).toBe(createdControllers[1]);
|
||||
// Window listeners are released with the controller that owned them.
|
||||
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
|
||||
'offline',
|
||||
'pagehide',
|
||||
]);
|
||||
});
|
||||
|
||||
it('destroy releases the controller, the node and the listeners', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app._destroyMobileImePreview();
|
||||
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
|
||||
expect(pagehide).toBeTypeOf('function');
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
expect(createdControllers[0].destroy).not.toHaveBeenCalled();
|
||||
expect(previewNodes[0].remove).not.toHaveBeenCalled();
|
||||
expect(app._mobileImePreview).toBe(createdControllers[0]);
|
||||
// A second hide after the page came back from the cache still works.
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets the controller exactly once when the active session changes', () => {
|
||||
const { app, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app.activeSessionId = 'session-b';
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||
app.sessions = new Map();
|
||||
app._updateLocalEchoState();
|
||||
app._updateLocalEchoState();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders and clears owned preview state', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '你好', phase: 'provisional' });
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
// With local echo on, typed text sits in the overlay (z-index 7) and the PTY
|
||||
// cursor that places the span stays at the prompt start, under that text. The
|
||||
// overlay draws the composition instead. Real-xterm proof of the covering:
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
function withOverlay(app: App, visible = true) {
|
||||
const overlay = {
|
||||
composition: '',
|
||||
setComposition: vi.fn(function (this: { composition: string }, text: string) {
|
||||
this.composition = text;
|
||||
}),
|
||||
state: { visible },
|
||||
};
|
||||
Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
|
||||
return overlay;
|
||||
}
|
||||
|
||||
it('routes the preview into the local echo overlay when local echo is on', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app);
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('');
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app, false);
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.composition).toBe('');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
|
||||
const off = createPreviewHarness();
|
||||
const offOverlay = withOverlay(off.app);
|
||||
Object.assign(off.app, { _localEchoEnabled: false });
|
||||
off.app._initMobileImePreview();
|
||||
off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(offOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
|
||||
const passthrough = createPreviewHarness();
|
||||
const passOverlay = withOverlay(passthrough.app);
|
||||
Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
|
||||
passthrough.app._initMobileImePreview();
|
||||
passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(passOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
|
||||
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
|
||||
themeForeground: '#1f2328',
|
||||
themeBackground: '#fafafa',
|
||||
});
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({
|
||||
fontFamily: compositionView.style.fontFamily,
|
||||
fontSize: compositionView.style.fontSize,
|
||||
fontWeight: compositionView.style.fontWeight,
|
||||
fontStyle: compositionView.style.fontStyle,
|
||||
lineHeight: compositionView.style.lineHeight,
|
||||
height: compositionView.style.height,
|
||||
color: '#1f2328',
|
||||
backgroundColor: '#fafafa',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the current skin theme when the terminal options carry no theme', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
|
||||
});
|
||||
|
||||
it('keeps rendering with a safe foreground when the theme getter throws', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
|
||||
app._initMobileImePreview();
|
||||
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
|
||||
expect(previewNodes[0].textContent).toBe('安全');
|
||||
expect(previewNodes[0].style.color).toBe('#e0e0e0');
|
||||
expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
|
||||
});
|
||||
|
||||
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
|
||||
'removes partial DOM ownership when %s fails',
|
||||
(failure) => {
|
||||
const removed = vi.fn();
|
||||
const owner = fakeClassList();
|
||||
const preview = new Proxy(
|
||||
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
|
||||
{
|
||||
set(target, property, value) {
|
||||
if (property === failure) throw new Error(`${failure} failed`);
|
||||
return Reflect.set(target, property, value);
|
||||
},
|
||||
}
|
||||
);
|
||||
const helpers = {
|
||||
classList: owner,
|
||||
appendChild:
|
||||
failure === 'append'
|
||||
? () => {
|
||||
throw new Error('append failed');
|
||||
}
|
||||
: vi.fn(),
|
||||
};
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement:
|
||||
failure === 'create'
|
||||
? () => {
|
||||
throw new Error('create failed');
|
||||
}
|
||||
: () => preview,
|
||||
};
|
||||
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
|
||||
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
element: {
|
||||
querySelector:
|
||||
failure === 'query'
|
||||
? () => {
|
||||
throw new Error('query failed');
|
||||
}
|
||||
: () => helpers,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(MobileImePreview.create).not.toHaveBeenCalled();
|
||||
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
|
||||
* so these cases exercise the shipped routing rather than a copy of it.
|
||||
*/
|
||||
function loadHandleTerminalData(app: App, sent: string[]) {
|
||||
const marker = 'const handleTerminalData = (data) => {';
|
||||
const start = terminalSource.indexOf(marker);
|
||||
if (start < 0) throw new Error('handleTerminalData definition not found');
|
||||
const bodyStart = start + marker.length;
|
||||
const end = terminalSource.indexOf('\n };', bodyStart);
|
||||
if (end < 0) throw new Error('handleTerminalData boundary not found');
|
||||
const timers: Array<() => void> = [];
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
document: { activeElement: null, getElementById: vi.fn(() => null) },
|
||||
window: {
|
||||
cjkActive: false,
|
||||
CodemanTerminalInput: {
|
||||
BRACKETED_PASTE_START: '\x1b[200~',
|
||||
shouldSuppressTerminalQueryResponse: () => false,
|
||||
isTerminalFocusOrMouseReport: () => false,
|
||||
isComposerNavKey: () => false,
|
||||
},
|
||||
},
|
||||
_crashDiag: { log: vi.fn() },
|
||||
flushInput: () => {
|
||||
app._inputFlushTimeout = null;
|
||||
if (app._pendingInput && app.activeSessionId) {
|
||||
const input = app._pendingInput;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync(app.activeSessionId, input);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
|
||||
this: App,
|
||||
data: string
|
||||
) => void;
|
||||
return { handle: (data: string) => handler.call(app, data), timers };
|
||||
}
|
||||
|
||||
describe('mobile IME commit onData routing', () => {
|
||||
function onDataApp(options: {
|
||||
localEcho: boolean;
|
||||
tagged?: boolean;
|
||||
overlayMissing?: boolean;
|
||||
addThrows?: boolean;
|
||||
appendThrows?: boolean;
|
||||
consumeThrows?: boolean;
|
||||
}) {
|
||||
const { mixin } = loadMixin();
|
||||
const sent: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: options.consumeThrows
|
||||
? vi.fn(() => {
|
||||
throw new Error('consume failed');
|
||||
})
|
||||
: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(options.tagged ?? true)
|
||||
.mockReturnValue(false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const overlay = {
|
||||
pendingText: '',
|
||||
appendText: vi.fn((data: string) => {
|
||||
if (options.appendThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
addChar: vi.fn((data: string) => {
|
||||
if (options.addThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
overlay.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
activeSessionId: 'session-a',
|
||||
_localEchoEnabled: options.localEcho,
|
||||
_localEchoOverlay: options.overlayMissing ? null : overlay,
|
||||
_echoPassthroughSessions: new Set(),
|
||||
_flushedOffsets: new Map(),
|
||||
_flushedTexts: new Map(),
|
||||
_pendingInput: '',
|
||||
_inputFlushTimeout: null,
|
||||
_lastKeystrokeTime: 0,
|
||||
_terminalOutputSeq: 5,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
|
||||
}
|
||||
|
||||
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
|
||||
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
|
||||
handle('你好');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(overlay.appendText).toHaveBeenCalledWith('你好');
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(app._mobileImeCommitOutputSeq).toBeNull();
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
handle('\r');
|
||||
expect(sent).toEqual(['你好']);
|
||||
timers.shift()?.();
|
||||
expect(sent).toEqual(['你好', '\r']);
|
||||
});
|
||||
|
||||
it('moves a single-character commit into local echo through addChar', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
|
||||
handle('界');
|
||||
expect(overlay.addChar).toHaveBeenCalledWith('界');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['appendText throws', { appendThrows: true }, '失敗'],
|
||||
['addChar throws', { addThrows: true }, '字'],
|
||||
])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
|
||||
// One code path with typed text: no send-on-throw fallback, which would
|
||||
// double-send if the overlay threw after appending.
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
|
||||
expect(() => handle(text)).toThrow('overlay failed');
|
||||
expect(sent).toEqual([]);
|
||||
// Nothing shows the text, so the preview keeps it until its fallback.
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the preview when the overlay is missing, exactly like typed text', () => {
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
|
||||
expect(() => handle('日本')).not.toThrow();
|
||||
expect(sent).toEqual([]);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the commit only after the printable branch has put it in the overlay', () => {
|
||||
const { controller, overlay, handle } = onDataApp({ localEcho: true });
|
||||
controller.completeCommit.mockImplementation(() => {
|
||||
expect(overlay.pendingText).toBe('界');
|
||||
});
|
||||
handle('界');
|
||||
expect(controller.completeCommit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps an untagged paste on the existing local echo path', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
|
||||
handle('plain paste');
|
||||
expect(overlay.pendingText).toBe('plain paste');
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends a commit once without local echo and holds the preview until output arrives', () => {
|
||||
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
|
||||
handle('日本語');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(sent).toEqual(['日本語']);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(app._mobileImeCommitOutputSeq).toBe(5);
|
||||
});
|
||||
|
||||
it('sends the original bytes exactly once when the controller throws', () => {
|
||||
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
|
||||
expect(() => handle('你好')).not.toThrow();
|
||||
expect(sent).toEqual(['你好']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME commit and authoritative terminal output', () => {
|
||||
function outputHarness(mode = 'claude') {
|
||||
const { mixin } = loadMixin();
|
||||
const parses: Array<() => void> = [];
|
||||
const written: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: vi.fn(() => true),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
pendingWrites: [],
|
||||
terminal: {
|
||||
rows: 24,
|
||||
write: vi.fn((data: string, callback?: () => void) => {
|
||||
written.push(data);
|
||||
if (callback) parses.push(callback);
|
||||
}),
|
||||
buffer: { active: { viewportY: 0 } },
|
||||
scrollToBottom: vi.fn(),
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode }]]),
|
||||
isTerminalAtBottom: () => true,
|
||||
_hasRecentUserScrollUp: () => false,
|
||||
_safeYield: vi.fn(),
|
||||
_localEchoOverlay: null,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
const flush = () => {
|
||||
app.writeFrameScheduled = false;
|
||||
app.flushPendingWrites();
|
||||
};
|
||||
const parseNext = () => parses.shift()?.();
|
||||
return { app, controller, written, flush, parseNext };
|
||||
}
|
||||
|
||||
it('clears the committed preview once output accepted after the commit is parsed', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output queued before the commit clear it, even when it parses after', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app.batchTerminalWrite('before');
|
||||
flush();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
|
||||
app.batchTerminalWrite('after');
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let a split chunk clear the commit before its remainder is written', () => {
|
||||
const { app, controller, written, flush, parseNext } = outputHarness('codex');
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('x'.repeat(40000));
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
flush();
|
||||
parseNext();
|
||||
expect(written.join('')).toBe('x'.repeat(40000));
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output parsed after a session switch clear the new session preview', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
app.activeSessionId = 'session-b';
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-places an overlay that holds only a composition after output (hasPending is false there)', () => {
|
||||
const { app, flush } = outputHarness();
|
||||
const overlay = { hasPending: false, pendingText: '', composition: '今日', rerender: vi.fn() };
|
||||
app._localEchoOverlay = overlay;
|
||||
app.batchTerminalWrite('output that moves the prompt');
|
||||
flush();
|
||||
expect(overlay.rerender).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('re-places it after a resize too: no rerender() site is gated on hasPending', () => {
|
||||
// The resize observer is a closure inside initTerminal(), so it is pinned
|
||||
// by source; the post-write follow runs against real xterm in
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
expect(terminalSource).toMatch(
|
||||
/this\.updateConnectionLines\(\);\s*(?:\/\/[^\n]*\n\s*)*this\._localEchoOverlay\?\.rerender\(\);/
|
||||
);
|
||||
expect(terminalSource).not.toMatch(/hasPending\)\s*\{?\s*this\._localEchoOverlay\.rerender\(\)/);
|
||||
});
|
||||
|
||||
it('notifies once per commit, never for later output', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
for (const chunk of ['a', 'b']) {
|
||||
app.batchTerminalWrite(chunk);
|
||||
flush();
|
||||
parseNext();
|
||||
}
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* The iOS IME preview controller against a REAL xterm 6 instance.
|
||||
*
|
||||
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
|
||||
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
|
||||
* `terminal.open()` registers xterm's keydown listener in the capture phase on
|
||||
* the helper textarea, and CompositionHelper.keydown finalizes a composition
|
||||
* there and emits the commit through onData synchronously. The controller must
|
||||
* observe that keydown first (capture phase on `terminal.element`), and must
|
||||
* finalize on exactly the keys xterm does.
|
||||
*
|
||||
* No server: a blank page loads the vendored xterm bundle and the controller.
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
type Step =
|
||||
| ['start']
|
||||
| ['update', string]
|
||||
| ['end', string]
|
||||
| ['key', number, string, boolean]
|
||||
| ['wait', number]
|
||||
| ['consume', string];
|
||||
|
||||
describe('mobile IME preview with real xterm', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
async function drive(steps: Step[]) {
|
||||
return page.evaluate(async (steps: Step[]) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal();
|
||||
term.open(host);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
const renders: Array<{ text: string; phase: string }> = [];
|
||||
const onData: Array<{ data: string; consumed: boolean }> = [];
|
||||
const controller = w.MobileImePreview.create({
|
||||
textarea,
|
||||
keydownTarget: term.element,
|
||||
render: (r: { text: string; phase: string }) => renders.push(r),
|
||||
clear: () => {},
|
||||
});
|
||||
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
|
||||
textarea.focus();
|
||||
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
for (const step of steps) {
|
||||
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
if (step[0] === 'update') {
|
||||
textarea.value = step[1];
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
|
||||
}
|
||||
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
|
||||
if (step[0] === 'key') {
|
||||
const [, keyCode, key, isComposing] = step;
|
||||
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
if (step[0] === 'wait') await tick(step[1]);
|
||||
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
|
||||
}
|
||||
await tick(20);
|
||||
const { composing, awaitingCommit, committed, latest } = controller.state;
|
||||
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
|
||||
controller.destroy();
|
||||
term.dispose();
|
||||
return result;
|
||||
}, steps);
|
||||
}
|
||||
|
||||
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
|
||||
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
|
||||
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
|
||||
expect(result.onData).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
|
||||
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
|
||||
});
|
||||
|
||||
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'か'],
|
||||
['wait', 10],
|
||||
['key', 229, 'k', false],
|
||||
['update', 'かな'],
|
||||
]);
|
||||
expect(result.onData).toEqual([]);
|
||||
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
|
||||
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
|
||||
});
|
||||
|
||||
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'abc'],
|
||||
['update', ''],
|
||||
['end', ''],
|
||||
['wait', 2100],
|
||||
['consume', 'pasted'],
|
||||
]);
|
||||
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The preview with local echo ON, the default for Claude sessions on phones.
|
||||
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
|
||||
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
|
||||
* which is where the helper span sits, stays at the prompt start: under the
|
||||
* overlay's own opaque text. So a composition that follows text already in the
|
||||
* overlay must be drawn by the overlay itself, after that text.
|
||||
*
|
||||
* Loads the real pieces: xterm 6, the overlay bundled from its package source
|
||||
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
|
||||
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
|
||||
* `_initMobileImePreview` on a bare CodemanApp prototype.
|
||||
*/
|
||||
describe('mobile IME preview over the local echo overlay', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bundled = await build({
|
||||
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'XtermZerolagInput',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
const overlayBundle =
|
||||
bundled.outputFiles[0].text +
|
||||
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
|
||||
await page.setContent(
|
||||
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
|
||||
);
|
||||
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
|
||||
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ content: overlayBundle });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Types `pending` into the overlay (as the printable/paste branch does), then
|
||||
* composes `composing` and reports what is PAINTED at the cell right after
|
||||
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
|
||||
* with pointer-events forced on, since the overlay and the preview are
|
||||
* pointer-events:none.
|
||||
*/
|
||||
async function composeAfter(pending: string, composing: string, commit: boolean) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing, commit }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('\u276f ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
app.terminal = term;
|
||||
app._localEchoEnabled = true;
|
||||
app._localEchoOverlay = new w.LocalEchoOverlay(term);
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
|
||||
// _syncMobileHelperTextareaToCursor places them.
|
||||
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
|
||||
const dims = term._core._renderService.dimensions.css.cell;
|
||||
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
|
||||
term.element.style.setProperty('--xterm-helper-top', '0px');
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const force = document.createElement('style');
|
||||
force.textContent = '.xterm * { pointer-events: auto !important; }';
|
||||
document.head.appendChild(force);
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
|
||||
const paintedAt = (col: number) => {
|
||||
const el = document.elementFromPoint(
|
||||
rect.left + (col + 0.5) * dims.width,
|
||||
rect.top + 0.5 * dims.height
|
||||
) as HTMLElement | null;
|
||||
return {
|
||||
text: el?.textContent ?? null,
|
||||
composition: !!el?.closest?.('[data-zerolag-composition]'),
|
||||
preview: !!el?.closest?.('.codeman-ime-preview'),
|
||||
};
|
||||
};
|
||||
const afterPending = paintedAt(2 + widthOf(pending));
|
||||
force.remove();
|
||||
|
||||
let afterCommit = null;
|
||||
if (commit) {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
|
||||
// What the printable/paste branch of terminal-ui.js's onData does.
|
||||
if (app._consumeMobileImeTerminalData(composing)) {
|
||||
app._localEchoOverlay.appendText(composing);
|
||||
app._transferMobileImeCommitToLocalEcho();
|
||||
}
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
afterCommit = {
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
|
||||
overlayText: app._localEchoOverlay._overlay?.textContent,
|
||||
};
|
||||
}
|
||||
const result = {
|
||||
afterPending,
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
afterCommit,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing, commit }
|
||||
);
|
||||
}
|
||||
|
||||
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
|
||||
const result = await composeAfter('', '今日は', false);
|
||||
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
|
||||
expect(result.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('a second composition is painted after the text already in the overlay, not under it', async () => {
|
||||
const result = await composeAfter('今日は', '天気', false);
|
||||
expect(result.afterPending.text).toBe('天');
|
||||
expect(result.afterPending.composition).toBe(true);
|
||||
// Provisional text is never taken into the overlay's pending (unsent) text.
|
||||
expect(result.pendingText).toBe('今日は');
|
||||
});
|
||||
|
||||
it('the commit lands once in the overlay and the composition tail is gone', async () => {
|
||||
const result = await composeAfter('今日は', '天気', true);
|
||||
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Composes `composing` after `pending`, then streams output through the REAL
|
||||
* write path (batchTerminalWrite, the scheduled flushPendingWrites, xterm's
|
||||
* async parse) that moves the ❯ row from 0 to 3, then one more frame that
|
||||
* leaves the prompt where it is (a status-line repaint). Reports the overlay
|
||||
* row after each frame.
|
||||
*
|
||||
* The post-write re-place runs right after terminal.write() returns, before
|
||||
* xterm parses that chunk, so it sees the buffer as of the previous frame: the
|
||||
* overlay reaches the new row on the frame after the move. That timing is the
|
||||
* same for pending text; the composition-only overlay used to never get there
|
||||
* because the re-place was gated on hasPending, which excludes it.
|
||||
*/
|
||||
async function composeThenMovePrompt(pending: string, composing: string) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('❯ ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
Object.assign(app, {
|
||||
terminal: term,
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: new w.LocalEchoOverlay(term),
|
||||
pendingWrites: [],
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode: 'claude' }]]),
|
||||
});
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const cellH = term._core._renderService.dimensions.css.cell.height;
|
||||
const overlayEl = app._localEchoOverlay._overlay as HTMLElement;
|
||||
const overlayRow = () =>
|
||||
overlayEl.style.display === 'none' ? null : Math.round(parseFloat(overlayEl.style.top) / cellH);
|
||||
// Output goes through the app's own scheduler; wait until it has been
|
||||
// flushed and parsed.
|
||||
const stream = async (data: string) => {
|
||||
app.batchTerminalWrite(data);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (!app.writeFrameScheduled && !app._terminalWriteInFlight && app.pendingWrites.length === 0) break;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
await new Promise<void>((r) => term.write('', () => r()));
|
||||
};
|
||||
|
||||
const before = overlayRow();
|
||||
await stream('\r\x1b[2Kline 1\r\nline 2\r\nline 3\r\n❯ ');
|
||||
const promptRow = app._localEchoOverlay.findPrompt()?.row ?? null;
|
||||
await stream('\x1b7\x1b[8;1Hworking\x1b8');
|
||||
const result = {
|
||||
before,
|
||||
promptRow,
|
||||
after: overlayRow(),
|
||||
composition: Array.from(term.element.querySelectorAll('[data-zerolag-composition]'))
|
||||
.map((el) => (el as HTMLElement).textContent)
|
||||
.join(''),
|
||||
hasPending: app._localEchoOverlay.hasPending,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing }
|
||||
);
|
||||
}
|
||||
|
||||
it('a composition on an empty prompt follows the prompt when output moves it', async () => {
|
||||
const result = await composeThenMovePrompt('', '今日');
|
||||
expect(result.before).toBe(0);
|
||||
expect(result.promptRow).toBe(3);
|
||||
// Nothing is pending: before the fix this stayed on row 0, over "line 1".
|
||||
expect(result.hasPending).toBe(false);
|
||||
expect(result.after).toBe(3);
|
||||
expect(result.composition).toBe('今日');
|
||||
});
|
||||
|
||||
it('a composition after pending text follows it the same way', async () => {
|
||||
const result = await composeThenMovePrompt('abc', '今日');
|
||||
expect(result).toEqual({ before: 0, promptRow: 3, after: 3, composition: '今日', hasPending: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
type ListenerOptions = boolean | { capture?: boolean };
|
||||
type RegisteredListener = { listener: Listener; capture: boolean };
|
||||
|
||||
/**
|
||||
* A DOM node with just enough event dispatch to reproduce listener ORDER: an
|
||||
* ancestor's capture listeners, then the target's capture listeners, then the
|
||||
* target's bubble listeners (at-target capture-first, as in Chromium 89+ and
|
||||
* WebKit), then the ancestor's bubble listeners.
|
||||
*/
|
||||
class FakeNode {
|
||||
parent: FakeNode | null = null;
|
||||
protected listeners = new Map<string, RegisteredListener[]>();
|
||||
|
||||
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push({ listener, capture: options === true || options?.capture === true });
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const capture = options === true || options?.capture === true;
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
const index = listeners.findIndex(
|
||||
(registered) => registered.listener === listener && registered.capture === capture
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}
|
||||
|
||||
run(type: string, capture: boolean, event: Record<string, unknown>) {
|
||||
for (const registered of [...(this.listeners.get(type) ?? [])]) {
|
||||
if (registered.capture === capture) registered.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const full = { type, target: this, ...event };
|
||||
const ancestors: FakeNode[] = [];
|
||||
for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
|
||||
for (const ancestor of ancestors) ancestor.run(type, true, full);
|
||||
this.run(type, true, full);
|
||||
this.run(type, false, full);
|
||||
for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTextarea extends FakeNode {
|
||||
value = 'unchanged';
|
||||
}
|
||||
|
||||
type Scheduled = { id: number; callback: () => void; delay?: number };
|
||||
|
||||
function harness(
|
||||
overrides: Record<string, unknown> = {},
|
||||
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | undefined) => void
|
||||
) {
|
||||
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
|
||||
const context = vm.createContext({ navigator: {} });
|
||||
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
|
||||
const api = vm.runInContext('MobileImePreview', context);
|
||||
// The terminal element: an ancestor of the helper textarea, like xterm's
|
||||
// `.xterm` root is of `.xterm-helper-textarea`.
|
||||
const element = new FakeNode();
|
||||
const textarea = new FakeTextarea();
|
||||
textarea.parent = element;
|
||||
const frames: Scheduled[] = [];
|
||||
const timers: Scheduled[] = [];
|
||||
let nextId = 1;
|
||||
const render = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const scheduleFrame = vi.fn((callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.push({ id, callback });
|
||||
return id;
|
||||
});
|
||||
const cancelFrame = vi.fn((id: number) => {
|
||||
const index = frames.findIndex((frame) => frame.id === id);
|
||||
if (index >= 0) frames.splice(index, 1);
|
||||
});
|
||||
const setTimer = vi.fn((callback: () => void, delay: number) => {
|
||||
const id = nextId++;
|
||||
timers.push({ id, callback, delay });
|
||||
return id;
|
||||
});
|
||||
const clearTimer = vi.fn((id: number) => {
|
||||
const index = timers.findIndex((timer) => timer.id === id);
|
||||
if (index >= 0) timers.splice(index, 1);
|
||||
});
|
||||
let controller: Record<string, unknown> | undefined;
|
||||
beforeCreate?.(textarea, () => controller);
|
||||
controller = api.create({
|
||||
textarea,
|
||||
keydownTarget: element,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
...overrides,
|
||||
});
|
||||
const flushFrame = () => frames.shift()?.callback();
|
||||
const flushTimer = () => timers.shift()?.callback();
|
||||
|
||||
return {
|
||||
api,
|
||||
element,
|
||||
textarea,
|
||||
frames,
|
||||
timers,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
controller,
|
||||
flushFrame,
|
||||
flushTimer,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileImePreview', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
test('collapses 500 composition updates into one latest-state frame', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
|
||||
|
||||
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.textarea.value).toBe('unchanged');
|
||||
});
|
||||
|
||||
test('replaces provisional text for replacement, backspace, and composing input', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
|
||||
h.textarea.dispatch('compositionupdate', { data: 'xy' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
|
||||
|
||||
h.textarea.dispatch('input', { data: '', isComposing: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps the preview without changing the terminal handoff value', () => {
|
||||
const h = harness();
|
||||
const value = '界'.repeat(2050);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: value });
|
||||
h.flushFrame();
|
||||
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
|
||||
h.textarea.dispatch('compositionend', { data: value });
|
||||
expect(h.controller.consumeTerminalData(value)).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
test('hands off only the first safe xterm onData value after finalization', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '日本語' });
|
||||
h.textarea.dispatch('compositionend', { data: '日本語' });
|
||||
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
|
||||
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
|
||||
});
|
||||
|
||||
test('defers finalization rendering and updates the queued frame phase to committed', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
|
||||
expect(h.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(h.frames).toHaveLength(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
|
||||
});
|
||||
|
||||
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'teh' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
|
||||
expect(h.controller.consumeTerminalData('the')).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.onCommit).toHaveBeenCalledWith('the');
|
||||
expect(h.controller.consumeTerminalData('teh')).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
|
||||
'rejects non-printable or multiline terminal data %j without consuming the pending value',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: rejected });
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['line\u2028break', 'line\u2029break'])(
|
||||
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('safe')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('keydown can finalize composition before a late compositionend', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.consumeTerminalData('確定')).toBe(true);
|
||||
h.textarea.dispatch('compositionend', { data: 'stale' });
|
||||
expect(h.controller.consumeTerminalData('stale')).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
|
||||
* listener on the helper textarea (CoreBrowserTerminal.ts:379), and
|
||||
* CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
|
||||
* composition there, emitting the commit through onData synchronously, for
|
||||
* every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
|
||||
* controller is created, exactly as terminal.open() precedes
|
||||
* _initMobileImePreview().
|
||||
*/
|
||||
function withXtermStandIn() {
|
||||
const emitted: Array<{ data: string; consumed: boolean }> = [];
|
||||
let composing = false;
|
||||
let composition = '';
|
||||
const h = harness({}, (textarea, getController) => {
|
||||
const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
|
||||
textarea.addEventListener('compositionstart', () => {
|
||||
composing = true;
|
||||
composition = '';
|
||||
});
|
||||
textarea.addEventListener('compositionupdate', (event) => {
|
||||
composition = String(event.data ?? '');
|
||||
});
|
||||
textarea.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
|
||||
composing = false;
|
||||
emit(composition);
|
||||
}
|
||||
if (event.keyCode === 13) emit('\r');
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
return { ...h, emitted, isXtermComposing: () => composing };
|
||||
}
|
||||
|
||||
test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.flushFrame();
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).toHaveBeenCalledWith('確定');
|
||||
expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
|
||||
|
||||
// The next unrelated keystroke is ordinary input, not an IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
// keyCode 229 with isComposing:false and a real key identity: xterm keeps
|
||||
// composing, so the controller must too.
|
||||
['the IME composition character', 'k', 229],
|
||||
['CapsLock', 'CapsLock', 20],
|
||||
['Shift', 'Shift', 16],
|
||||
['Control', 'Control', 17],
|
||||
['Alt', 'Alt', 18],
|
||||
])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'か' });
|
||||
h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
|
||||
expect(h.isXtermComposing()).toBe(true);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
|
||||
// The preview follows the composition instead of freezing on the old value.
|
||||
h.textarea.dispatch('compositionupdate', { data: 'かな' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores keydowns that did not target the helper textarea', () => {
|
||||
const h = harness();
|
||||
const sibling = new FakeNode();
|
||||
sibling.parent = h.element;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '漢字' });
|
||||
sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
});
|
||||
|
||||
test('destroy stops the controller observing keydown on the terminal element', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.controller.destroy();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'later' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: 'later', consumed: false },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
describe('a commit that never reaches onData', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function realTimerHarness() {
|
||||
return harness({
|
||||
setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
clearTimer: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
|
||||
});
|
||||
}
|
||||
|
||||
test.each([
|
||||
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
|
||||
[
|
||||
'a finalizing keydown',
|
||||
(h: ReturnType<typeof harness>) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
|
||||
],
|
||||
])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'deleted' });
|
||||
finalize(h);
|
||||
h.clear.mockClear();
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
|
||||
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(h.controller.state.awaitingCommit).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
// The next unrelated keystroke or paste is not adopted as the IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a commit that arrives in time replaces the wait bound with the committed one', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '日本' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(h.controller.consumeTerminalData('日本')).toBe(true);
|
||||
// The wait bound would have fired at 2000 ms; the committed bound runs
|
||||
// a full 2 s from the commit instead.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('a new composition cancels the previous wait bound', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'next' });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
|
||||
});
|
||||
});
|
||||
|
||||
test('generation fences stale frames and timers', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'old' });
|
||||
const staleFrame = h.frames[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
staleFrame();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
|
||||
h.textarea.dispatch('compositionend', { data: 'first' });
|
||||
h.controller.consumeTerminalData('first');
|
||||
const staleTimer = h.timers[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'new' });
|
||||
h.flushFrame();
|
||||
staleTimer();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
|
||||
});
|
||||
|
||||
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
|
||||
const predicted = harness();
|
||||
predicted.textarea.dispatch('compositionstart');
|
||||
predicted.clear.mockClear();
|
||||
predicted.textarea.dispatch('compositionend', { data: 'one' });
|
||||
predicted.controller.consumeTerminalData('one');
|
||||
expect(predicted.frames).toHaveLength(1);
|
||||
predicted.controller.completeCommit({ predicted: true });
|
||||
expect(predicted.clear).toHaveBeenCalledOnce();
|
||||
expect(predicted.frames).toHaveLength(0);
|
||||
expect(predicted.timers).toHaveLength(0);
|
||||
expect(predicted.controller.state.latest).toBe('');
|
||||
expect(predicted.controller.state.committed).toBe(false);
|
||||
|
||||
const fallback = harness();
|
||||
fallback.textarea.dispatch('compositionstart');
|
||||
fallback.clear.mockClear();
|
||||
fallback.textarea.dispatch('compositionend', { data: 'two' });
|
||||
fallback.controller.consumeTerminalData('two');
|
||||
fallback.controller.completeCommit({ predicted: false });
|
||||
expect(fallback.clear).not.toHaveBeenCalled();
|
||||
fallback.controller.noteAuthoritativeOutput();
|
||||
expect(fallback.clear).toHaveBeenCalledOnce();
|
||||
expect(fallback.frames).toHaveLength(0);
|
||||
expect(fallback.timers).toHaveLength(0);
|
||||
expect(fallback.controller.state.latest).toBe('');
|
||||
expect(fallback.controller.state.committed).toBe(false);
|
||||
|
||||
const ttl = harness();
|
||||
ttl.textarea.dispatch('compositionstart');
|
||||
ttl.clear.mockClear();
|
||||
ttl.textarea.dispatch('compositionend', { data: 'three' });
|
||||
ttl.controller.consumeTerminalData('three');
|
||||
ttl.controller.completeCommit({ predicted: false });
|
||||
ttl.flushTimer();
|
||||
expect(ttl.clear).toHaveBeenCalledOnce();
|
||||
expect(ttl.frames).toHaveLength(0);
|
||||
expect(ttl.timers).toHaveLength(0);
|
||||
expect(ttl.controller.state.latest).toBe('');
|
||||
expect(ttl.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
|
||||
let resetController: { reset(): void };
|
||||
const reset = harness({ onCommit: () => resetController.reset() });
|
||||
resetController = reset.controller;
|
||||
reset.textarea.dispatch('compositionstart');
|
||||
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
reset.textarea.dispatch('compositionend');
|
||||
expect(reset.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(reset.frames).toHaveLength(0);
|
||||
expect(reset.timers).toHaveLength(0);
|
||||
expect(reset.controller.state.latest).toBe('');
|
||||
|
||||
let destroyController: { destroy(): void };
|
||||
const destroy = harness({ onCommit: () => destroyController.destroy() });
|
||||
destroyController = destroy.controller;
|
||||
destroy.textarea.dispatch('compositionstart');
|
||||
destroy.textarea.dispatch('compositionend');
|
||||
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(destroy.textarea.listenerCount()).toBe(0);
|
||||
expect(destroy.element.listenerCount()).toBe(0);
|
||||
expect(destroy.frames).toHaveLength(0);
|
||||
expect(destroy.timers).toHaveLength(0);
|
||||
expect(destroy.controller.state.latest).toBe('');
|
||||
});
|
||||
|
||||
test('contains re-entrant render and clear callbacks', () => {
|
||||
let renderController: { reset(): void };
|
||||
const render = harness({ render: () => renderController.reset() });
|
||||
renderController = render.controller;
|
||||
render.textarea.dispatch('compositionstart');
|
||||
render.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
expect(() => render.flushFrame()).not.toThrow();
|
||||
expect(render.frames).toHaveLength(0);
|
||||
expect(render.timers).toHaveLength(0);
|
||||
expect(render.controller.state.latest).toBe('');
|
||||
|
||||
let clearController: { destroy(): void };
|
||||
const clear = harness({ clear: () => clearController?.destroy() });
|
||||
clearController = clear.controller;
|
||||
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
|
||||
expect(clear.textarea.listenerCount()).toBe(0);
|
||||
expect(clear.element.listenerCount()).toBe(0);
|
||||
expect(clear.frames).toHaveLength(0);
|
||||
expect(clear.timers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('fails open when frame or timer schedulers throw', () => {
|
||||
const frame = harness({
|
||||
scheduleFrame: () => {
|
||||
throw new Error('frame scheduler');
|
||||
},
|
||||
});
|
||||
frame.textarea.dispatch('compositionstart');
|
||||
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
|
||||
expect(frame.controller.state.framePending).toBe(false);
|
||||
|
||||
let lateFrame: (() => void) | undefined;
|
||||
const timer = harness({
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
lateFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
setTimer: () => {
|
||||
throw new Error('timer scheduler');
|
||||
},
|
||||
});
|
||||
timer.textarea.dispatch('compositionstart');
|
||||
timer.clear.mockClear();
|
||||
timer.textarea.dispatch('compositionend');
|
||||
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(timer.controller.state.committed).toBe(false);
|
||||
expect(timer.controller.state.latest).toBe('');
|
||||
expect(timer.controller.state.framePending).toBe(false);
|
||||
expect(timer.controller.state.timerPending).toBe(false);
|
||||
expect(timer.clear).toHaveBeenCalledOnce();
|
||||
lateFrame?.();
|
||||
expect(timer.render).not.toHaveBeenCalled();
|
||||
|
||||
const tokenlessTimer = harness({ setTimer: () => undefined });
|
||||
tokenlessTimer.textarea.dispatch('compositionstart');
|
||||
tokenlessTimer.clear.mockClear();
|
||||
tokenlessTimer.textarea.dispatch('compositionend');
|
||||
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
|
||||
expect(tokenlessTimer.controller.state.committed).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.latest).toBe('');
|
||||
expect(tokenlessTimer.controller.state.framePending).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
|
||||
expect(tokenlessTimer.frames).toHaveLength(0);
|
||||
expect(tokenlessTimer.timers).toHaveLength(0);
|
||||
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
|
||||
|
||||
const cancellation = harness({
|
||||
cancelFrame: () => {
|
||||
throw new Error('frame cancellation');
|
||||
},
|
||||
clearTimer: () => {
|
||||
throw new Error('timer cancellation');
|
||||
},
|
||||
});
|
||||
cancellation.textarea.dispatch('compositionstart');
|
||||
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
cancellation.textarea.dispatch('compositionend');
|
||||
cancellation.controller.consumeTerminalData('final');
|
||||
expect(() => cancellation.controller.reset()).not.toThrow();
|
||||
expect(cancellation.controller.state.framePending).toBe(false);
|
||||
expect(cancellation.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('authoritative output never clears active provisional composition', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'active' });
|
||||
h.flushFrame();
|
||||
h.controller.noteAuthoritativeOutput();
|
||||
expect(h.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('blur and reset cancel scheduled work and clear visual state', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
h.textarea.dispatch('blur');
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'again' });
|
||||
h.controller.reset();
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('destroy is idempotent and removes listeners and scheduled work', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
expect(h.textarea.listenerCount()).toBe(5);
|
||||
expect(h.element.listenerCount()).toBe(1);
|
||||
h.controller.destroy();
|
||||
h.controller.destroy();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
expect(h.frames).toHaveLength(0);
|
||||
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
|
||||
expect(h.scheduleFrame).toHaveBeenCalledOnce();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.framePending).toBe(false);
|
||||
expect(h.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('contains render, clear, and commit callback exceptions', () => {
|
||||
const h = harness({
|
||||
render: vi.fn(() => {
|
||||
throw new Error('render');
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
throw new Error('clear');
|
||||
}),
|
||||
onCommit: vi.fn(() => {
|
||||
throw new Error('commit');
|
||||
}),
|
||||
});
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'safe' });
|
||||
expect(() => h.flushFrame()).not.toThrow();
|
||||
h.textarea.dispatch('compositionend', { data: 'safe' });
|
||||
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
|
||||
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
|
||||
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
|
||||
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,6 @@ describe('response viewer file-path linkifier', () => {
|
||||
// either leaves inert paths (no linkify) or dead links (no handler).
|
||||
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
||||
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ function makeApp() {
|
||||
getCaseSettings: () => ({}),
|
||||
buildEnvOverrides: () => ({}),
|
||||
getEffortSetting: () => undefined,
|
||||
getAdvisorSetting: () => undefined,
|
||||
selectSession: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
||||
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
|
||||
});
|
||||
|
||||
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
|
||||
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
||||
// fields would drop the effort here without a word.
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
'peon',
|
||||
{ dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
|
||||
});
|
||||
|
||||
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
|
||||
expect(out.codexConfig).toBeUndefined();
|
||||
|
||||
@@ -681,6 +681,18 @@ describe('file-routes', () => {
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 1024 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.type).toBe('image');
|
||||
});
|
||||
|
||||
it('returns audio metadata for audio files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||
|
||||
@@ -817,6 +829,19 @@ describe('file-routes', () => {
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
|
||||
const content = Buffer.from('fake avif data');
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/avif');
|
||||
});
|
||||
|
||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
|
||||
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
|
||||
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
|
||||
* config are reported as unsupported.
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
|
||||
* creates. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
|
||||
|
||||
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
|
||||
// is installed, so only a CLI whose config file exists takes part.
|
||||
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
|
||||
// (after taking the lock) until released, so a second POST deterministically overlaps it.
|
||||
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
|
||||
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
|
||||
return {
|
||||
...actual,
|
||||
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
|
||||
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
|
||||
const parked = new Promise<void>((resolve) => (hold.release = resolve));
|
||||
hold.entered?.();
|
||||
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
|
||||
await parked;
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Nothing is installed unless a test adds the id here.
|
||||
const installed = vi.hoisted(() => new Set<string>());
|
||||
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
|
||||
probeStockCliAvailability: async () => ({}),
|
||||
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
|
||||
}));
|
||||
|
||||
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
|
||||
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
|
||||
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
|
||||
const RELOCATION_VARS = STOCK_CLIS.flatMap((e) => {
|
||||
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
|
||||
return envVar ? [envVar] : [];
|
||||
});
|
||||
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
|
||||
afterAll(() => {
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
const home = () => homedir();
|
||||
const write = (rel: string, text: string) => {
|
||||
const f = join(home(), rel);
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const setEnabled = (on: boolean | undefined) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
|
||||
};
|
||||
const disable = (...ids: string[]) => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }]));
|
||||
writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
};
|
||||
|
||||
const CLAUDE = '.claude.json';
|
||||
const CODEX = '.codex/config.toml';
|
||||
const GEMINI = '.gemini/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of RELOCATION_VARS) delete process.env[k];
|
||||
installed.clear();
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
|
||||
rmSync(join(home(), d), { recursive: true, force: true });
|
||||
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
|
||||
// Codex and Gemini have been set up on this machine (their config files exist).
|
||||
write(CODEX, 'model = "gpt-5"\n');
|
||||
write(GEMINI, '{}');
|
||||
setEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync — opt-in', () => {
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['false', false],
|
||||
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
|
||||
setEnabled(value);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
expect(res.json().error).toMatch(/disabled/i);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('applies the toggle on the next request, with no restart', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
setEnabled(false);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
|
||||
setEnabled(true);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync', () => {
|
||||
it('GET previews without writing', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.applied).toBe(false);
|
||||
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('POST adds the server to every enabled, set-up CLI', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.json().data.applied).toBe(true);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
|
||||
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
|
||||
expect(opencode.status).toBe('absent');
|
||||
expect(existsSync(join(home(), '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches a CLI that is disabled in the registry', async () => {
|
||||
disable('codex');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
|
||||
expect(ids).not.toContain('codex');
|
||||
expect(ids).toContain('gemini');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
|
||||
installed.add('grok').add('pi');
|
||||
disable('pi');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
|
||||
expect(unsupported).toEqual(['Grok']);
|
||||
});
|
||||
|
||||
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
|
||||
const codexHome = join(home(), 'relocated/codex');
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(codex.added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
|
||||
process.env.CODEX_HOME = 'relative/codex';
|
||||
installed.add('codex');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME/);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('never echoes the text of a config file it cannot parse', async () => {
|
||||
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(res.body).not.toContain('lin_SECRET_abc');
|
||||
});
|
||||
|
||||
it('never returns env values or headers', async () => {
|
||||
write(
|
||||
CLAUDE,
|
||||
JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } })
|
||||
);
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.body).not.toContain('sekrit-value');
|
||||
});
|
||||
|
||||
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
hold.release = () => undefined;
|
||||
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
|
||||
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
await entered; // the first apply now holds the lock
|
||||
const release = hold.release;
|
||||
hold.release = null; // the overlapping request goes straight to the real function
|
||||
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(second.statusCode).toBe(409);
|
||||
expect(second.json().errorCode).toBe('CONFLICT');
|
||||
release?.();
|
||||
expect((await first).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('multi-user: an admin is allowed', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview `model` on POST /api/sessions — a Claude model for one session only.
|
||||
*
|
||||
* Claude's model reaches disk only through `modelOverride`, which writes it into the
|
||||
* case's `.claude/settings.local.json` for every later run there. `model` is the
|
||||
* per-session counterpart: it goes out as `claude --model <id>`, wins over the app-wide
|
||||
* default, and writes nothing. What the tests read is the model the session hands the
|
||||
* mux when it starts, which is what becomes the `--model` flag.
|
||||
*
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('POST /api/sessions model', () => {
|
||||
let workingDir: string;
|
||||
let harness: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-'));
|
||||
harness = await createHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Creates a session and starts it, then returns the model it handed the mux. */
|
||||
async function launchedModel(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = JSON.parse(res.body);
|
||||
const id = (parsed.data?.session ?? parsed.session).id as string;
|
||||
await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} });
|
||||
const calls = harness.ctx.mux.createSession.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return (calls[calls.length - 1][0] as { model?: string }).model;
|
||||
}
|
||||
|
||||
it('launches a Claude session on the model the caller names', async () => {
|
||||
expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('wins over the app-wide default model', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus');
|
||||
});
|
||||
|
||||
it('leaves the app-wide default in charge when the caller names none', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
|
||||
// Sent together, each lands where it belongs: the persistent default in the case's
|
||||
// settings.local.json, and this session's model on its launch line. A route that wrote
|
||||
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
|
||||
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(settings.model).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('reads an empty model as no model, as modelOverride does', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('refuses a model for a CLI that takes its model in its own config object', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('rejects a model with characters the launch pattern refuses', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a flag-shaped model, since the value lands in argv', async () => {
|
||||
for (const model of ['--dangerously-skip-permissions', '-p', '.hidden', '[1m]']) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model },
|
||||
});
|
||||
expect(res.statusCode, model).toBe(400);
|
||||
}
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('still accepts real model ids, aliases and the [1m] suffix', async () => {
|
||||
for (const model of ['claude-fable-5-1', 'opus', 'opus[1m]', 'claude-opus-5-5[1m]']) {
|
||||
expect(await launchedModel({ mode: 'claude', model })).toBe(model);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['model', { model: 'opus' }],
|
||||
['advisorModel', { advisorModel: 'opus' }],
|
||||
])('refuses %s on a remote attach, which launches nothing', async (_field, extra) => {
|
||||
// Refused before the host is looked up, so no remote host needs to exist.
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
mode: 'claude',
|
||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1);
|
||||
});
|
||||
|
||||
it('publishes the launch model on the created claude session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'claude-fable-5-1', advisorModel: 'opus' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
const session = parsed.data?.session ?? parsed.session;
|
||||
expect(session.model).toBe('claude-fable-5-1');
|
||||
expect(session.advisorModel).toBe('opus');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
@@ -165,6 +167,74 @@ describe('session-routes', () => {
|
||||
expect(argv).toContain('-H');
|
||||
});
|
||||
|
||||
describe('newline chord comes from the CLI registry (capabilities.newline)', () => {
|
||||
const sentHex = async (mode: string, key: string): Promise<string[]> => {
|
||||
execFile.mockReset();
|
||||
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
|
||||
cb(null)
|
||||
);
|
||||
const session = harness.ctx._session as unknown as { mode: string };
|
||||
const before = session.mode;
|
||||
session.mode = mode;
|
||||
try {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/send-key',
|
||||
payload: { key },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
session.mode = before;
|
||||
}
|
||||
const argv = execFile.mock.calls[0][1] as string[];
|
||||
return argv.slice(argv.indexOf('-H') + 3); // after "-H -t <pane>"
|
||||
};
|
||||
|
||||
it('sends a line feed for Shift+Enter to a CLI that declares nothing', async () => {
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('opencode', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('sends a line feed to Codex too: no stock CLI declares a chord', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
describe('a CLI that declares esc-enter (here via a user clis.json override of codex)', () => {
|
||||
beforeEach(() => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, clis: { codex: { capabilities: { newline: 'esc-enter' } } } }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
reloadCliRegistry();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
it('sends Esc+Enter for Shift+Enter, and only to that CLI', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['1b', '0d']);
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('still sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('always sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('claude', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('falls back to a line feed for a mode the registry does not know', async () => {
|
||||
expect(await sentHex('no-such-cli', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
|
||||
execFile.mockReset();
|
||||
const res = await harness.app.inject({
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
|
||||
* is never returned and the routes are admin only in multi-user mode.
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
|
||||
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
|
||||
|
||||
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
|
||||
|
||||
let dir: string;
|
||||
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
|
||||
|
||||
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
|
||||
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
|
||||
const h = await createRouteTestHarness(
|
||||
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
|
||||
authUser ? { authUser } : undefined
|
||||
);
|
||||
return { ...h, notifier };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
|
||||
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/webhook', () => {
|
||||
it('starts disabled with no URL', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(res.json().data).toEqual({
|
||||
enabled: false,
|
||||
kind: 'ntfy',
|
||||
scope: 'attention',
|
||||
hasUrl: false,
|
||||
urlMasked: '',
|
||||
lastResult: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/webhook', () => {
|
||||
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
|
||||
const { app } = await harness();
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/webhook',
|
||||
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
|
||||
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
|
||||
});
|
||||
|
||||
it('an empty url clears it', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
|
||||
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
|
||||
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
|
||||
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
|
||||
['clearing the URL while enabled', null, /Add a webhook URL/],
|
||||
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
|
||||
const { app } = await harness();
|
||||
if (payload === null) {
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
return;
|
||||
}
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
|
||||
});
|
||||
|
||||
it('rejects unknown keys and bad enums (strict schema)', async () => {
|
||||
const { app } = await harness();
|
||||
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/test', () => {
|
||||
it('refuses with 400 until a URL is saved', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one message with the saved config, even while notifications are disabled', async () => {
|
||||
const { app, notifier } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
|
||||
host: 'codeman:test',
|
||||
event: 'webhook:test',
|
||||
});
|
||||
expect(notifier.lastResult?.ok).toBe(true);
|
||||
expect(res.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
|
||||
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
|
||||
expect(get.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user', () => {
|
||||
it.each([
|
||||
['GET', '/api/webhook'],
|
||||
['PUT', '/api/webhook'],
|
||||
['POST', '/api/webhook/test'],
|
||||
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'bob', role: 'user' });
|
||||
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'root', role: 'admin' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @fileoverview A session's launch model survives recovery.
|
||||
*
|
||||
* `Session._model` is what becomes `claude --model <id>`: the caller's per-session `model`
|
||||
* from POST /api/sessions, or the app-wide default. It lives in memory, so it reaches a
|
||||
* relaunch after a Codeman restart or a reboot restore only if `toState()` persists it and
|
||||
* both recovery constructors hand it back. Without that, a recovered session silently
|
||||
* relaunches on the account default.
|
||||
*
|
||||
* `restoreMuxSessions()` (server.ts) cannot be reached under vitest, where
|
||||
* `reconcileSessions()` reports every pane alive, and the reboot-restore route rejects every
|
||||
* workspace before building a Session in its route tests. The two constructors are therefore
|
||||
* pinned by a source check, the same way `test/remote-wake.test.ts` pins its wiring, and the
|
||||
* round trip itself is driven through a real `Session` against the in-memory tmux layer.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager, buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, RespawnPaneOptions, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||
import type { EffortLevel } from '../src/types.js';
|
||||
|
||||
const SRC = fileURLToPath(new URL('../src', import.meta.url));
|
||||
|
||||
describe('the launch model survives recovery', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'session-model-recovery');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('persists the model in the session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude', model: 'claude-fable-5-1' });
|
||||
sessions.push(session);
|
||||
expect(session.toState().model).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('relaunches a session rebuilt from that state on the same model', async () => {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const original = new Session({ workingDir, mode: 'claude', model: 'claude-fable-5-1' });
|
||||
sessions.push(original);
|
||||
const state = original.toState();
|
||||
|
||||
// Rebuilt the way both recovery paths build one, from the persisted record.
|
||||
const mux = new TmuxManager();
|
||||
const createSession = vi.spyOn(mux, 'createSession');
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir,
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
model: state.model,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-fable-5-1' }));
|
||||
});
|
||||
|
||||
it('is handed back by both recovery constructors', () => {
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
|
||||
expect(server).toMatch(/model:\s*savedState\?\.model,/);
|
||||
expect(reboot).toMatch(/model:\s*saved\.model,/);
|
||||
});
|
||||
|
||||
it('is neither published nor persisted for a CLI that keeps its model in its own config', () => {
|
||||
// Cron hands the app-wide default (a Claude id) to every CLI that has a model at all.
|
||||
// codex never launches on the top-level field, so publishing it would report a model the
|
||||
// session never ran on, and recovery would carry that wrong value forward.
|
||||
for (const mode of ['codex', 'opencode', 'shell'] as const) {
|
||||
const session = new Session({ workingDir: '/tmp', mode, model: 'claude-fable-5-1' });
|
||||
sessions.push(session);
|
||||
expect(session.toState().model, mode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 (`advisorModel` → the launch's ONE
|
||||
* `--settings` JSON) landed together and touch the same launch and recovery code. A claude
|
||||
* session carrying both, with or without ultracode (whose blob shares that `--settings`
|
||||
* object), must relaunch with both on every recovery path.
|
||||
*/
|
||||
describe('a launch model and an advisor survive recovery together', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const ADVISOR = 'opus';
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of sessions.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
/** The settings JSON exactly as a shell would hand it to claude. */
|
||||
function settingsOf(cmd: string): unknown {
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
const tail = cmd.slice(cmd.indexOf('--settings '));
|
||||
return JSON.parse(execFileSync('bash', ['-c', `set -- ${tail}; printf '%s' "$2"`]).toString());
|
||||
}
|
||||
|
||||
/** Render what tmux-manager hands buildSpawnCommand for these options. */
|
||||
function launchLine(options: Pick<RespawnPaneOptions, 'sessionId' | 'model' | 'effort' | 'advisorModel'>): string {
|
||||
return buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: options.sessionId,
|
||||
model: options.model,
|
||||
effort: options.effort,
|
||||
advisorModel: options.advisorModel,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
}
|
||||
|
||||
function expectBoth(cmd: string, effort: EffortLevel | undefined): void {
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
expect(settingsOf(cmd)).toEqual(
|
||||
effort === 'ultracode' ? { ultracode: true, advisorModel: ADVISOR } : { advisorModel: ADVISOR }
|
||||
);
|
||||
if (effort && effort !== 'ultracode') expect(cmd).toContain(`--effort '${effort}'`);
|
||||
}
|
||||
|
||||
function persistedRecord(effort: EffortLevel | undefined) {
|
||||
const original = new Session({ workingDir: '/tmp', mode: 'claude', model: MODEL, advisorModel: ADVISOR, effort });
|
||||
sessions.push(original);
|
||||
const state = original.toState();
|
||||
expect(state.model).toBe(MODEL);
|
||||
expect(state.advisorModel).toBe(ADVISOR);
|
||||
return state;
|
||||
}
|
||||
|
||||
it.each([['ultracode'], ['high'], [undefined]] as const)(
|
||||
'reboot restore (fresh pane) relaunches on both, effort %s',
|
||||
async (effort) => {
|
||||
const state = persistedRecord(effort);
|
||||
// The reboot-restore constructor: no muxSession, so startInteractive() creates a pane.
|
||||
const mux = new TmuxManager();
|
||||
const createSession = vi.spyOn(mux, 'createSession');
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir: '/tmp',
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
effort: state.effort,
|
||||
model: state.model,
|
||||
advisorModel: state.advisorModel,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
const options = createSession.mock.calls[0][0];
|
||||
expect(options).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
|
||||
expectBoth(launchLine(options), effort);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([['ultracode'], ['high'], [undefined]] as const)(
|
||||
'restoreMuxSessions onto a dead pane respawns on both, effort %s',
|
||||
async (effort) => {
|
||||
const state = persistedRecord(effort);
|
||||
const respawns: RespawnPaneOptions[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => true,
|
||||
isPaneDead: () => true,
|
||||
setAttached: () => {},
|
||||
respawnPane: async (options: RespawnPaneOptions) => {
|
||||
respawns.push(options);
|
||||
return 4242;
|
||||
},
|
||||
} as unknown as TerminalMultiplexer;
|
||||
// The restoreMuxSessions() constructor: an existing muxSession, whose pane is dead, so
|
||||
// startInteractive() takes the dead-pane respawn.
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir: '/tmp',
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
muxSession: { muxName: 'codeman-aaaa', sessionId: state.id } as unknown as MuxSession,
|
||||
effort: state.effort,
|
||||
model: state.model,
|
||||
advisorModel: state.advisorModel,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(respawns).toHaveLength(1);
|
||||
expect(respawns[0]).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
|
||||
expectBoth(launchLine(respawns[0]), effort);
|
||||
}
|
||||
);
|
||||
|
||||
it('both recovery constructors hand back model, advisorModel and effort', () => {
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
|
||||
for (const field of ['effort', 'model', 'advisorModel']) {
|
||||
expect(server).toMatch(new RegExp(`\\b${field}:\\s*savedState\\?\\.${field},`));
|
||||
expect(reboot).toMatch(new RegExp(`\\b${field}:\\s*saved\\.${field},`));
|
||||
}
|
||||
});
|
||||
|
||||
it('tmux-manager forwards model, effort and advisorModel to both launch builders', () => {
|
||||
// createSession() and respawnPane() each build the pane command. The tests above stop at
|
||||
// the options a Session hands them, so this pins the last hop to the builder.
|
||||
const tmux = readFileSync(join(SRC, 'tmux-manager.ts'), 'utf-8');
|
||||
const calls = [...tmux.matchAll(/buildSpawnCommand\(\{([^}]*)\}\)/g)].map((m) => m[1]);
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
for (const field of ['model', 'effort', 'advisorModel']) expect(call).toMatch(new RegExp(`\\b${field},`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,10 @@
|
||||
* `selectSession()` acknowledges the session's idle approval item server-side
|
||||
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
|
||||
* what makes "I checked it" survive a reload and reach the user's other
|
||||
* devices. Three call sites are the APP choosing a session rather than the
|
||||
* user: the boot restore, a solo (popped-out) window opening its target, and
|
||||
* the fallback after the active session is deleted. Those pass `auto: true`
|
||||
* devices. Four call sites are the APP choosing a session rather than the
|
||||
* user: the boot restore, a solo (popped-out) window opening its target, a
|
||||
* `#session=<id>` link from another page, and the fallback after the active
|
||||
* session is deleted. Those pass `auto: true`
|
||||
* and must not spend the alert, or a yellow tab would clear itself every time
|
||||
* the page loaded and the user would never see it.
|
||||
*
|
||||
@@ -110,7 +111,7 @@ describe('selectSession acknowledgement gate', () => {
|
||||
});
|
||||
|
||||
describe('the call sites the app drives itself', () => {
|
||||
// Source guard: these three are the reason the flag exists. If a refactor
|
||||
// Source guard: these call sites are the reason the flag exists. If a refactor
|
||||
// moves or reformats them, fail loudly rather than silently going back to
|
||||
// "every page load clears the user's yellow tab".
|
||||
it.each([
|
||||
@@ -118,6 +119,7 @@ describe('selectSession acknowledgement gate', () => {
|
||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
||||
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
||||
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
||||
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
|
||||
])('%s passes auto: true', (_label, call) => {
|
||||
expect(APP_SOURCE).toContain(call);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
// Regression guard: xterm runs the custom key handler for keydown AND keypress.
|
||||
// It discards a keypress carrying Ctrl/Alt but not one carrying only Shift, so
|
||||
// a handler that returns false for keydown alone lets Shift+Enter's keypress
|
||||
// through as a bare \r (submit). The Enter gate must therefore not be keyed on
|
||||
// ev.type === 'keydown'; only the send-key fetch is.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
||||
|
||||
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
||||
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
||||
|
||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||
expect(src).toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\)\) \{/);
|
||||
expect(src).not.toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\) && ev\.type === 'keydown'/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user