mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a5f5919e | ||
|
|
ecd3f3f32a | ||
|
|
c19d884a51 | ||
|
|
22e77a1827 | ||
|
|
b641560040 | ||
|
|
8300c15cbd | ||
|
|
09f5f28017 | ||
|
|
251706be3b | ||
|
|
18b473f0e4 | ||
|
|
a2aed38073 | ||
|
|
e888c65c52 | ||
|
|
1ea39de650 | ||
|
|
e2a644997e | ||
|
|
cd5a101626 | ||
|
|
4ea781c80f | ||
|
|
d9123de9eb | ||
|
|
1e5f6c8ee1 | ||
|
|
5d2899907e | ||
|
|
8facd5e7e7 | ||
|
|
b54094a4c8 | ||
|
|
2b89f35599 | ||
|
|
ee670c38f6 | ||
|
|
ad57109dcf | ||
|
|
c15b8345b5 | ||
|
|
45ae9f4064 | ||
|
|
816d900857 | ||
|
|
ddc267c6ff | ||
|
|
d66007053b | ||
|
|
f470f3a4e7 | ||
|
|
cb3eecad9b | ||
|
|
db24fc6d7e | ||
|
|
292ba2c775 | ||
|
|
e803186dfe | ||
|
|
474efd9023 | ||
|
|
03bb40c78a | ||
|
|
3ea1ea28f0 | ||
|
|
62008fb408 | ||
|
|
660b320a67 | ||
|
|
529d8fa8ea | ||
|
|
19af37977a | ||
|
|
23f258a85d | ||
|
|
aa4f423d8a | ||
|
|
1b1057d9e0 | ||
|
|
26cbbe0dcb | ||
|
|
1113d34ca8 | ||
|
|
ab7a703e90 | ||
|
|
8a31f10b7d | ||
|
|
2891ae0d6d | ||
|
|
17b86b1007 | ||
|
|
73315bc351 | ||
|
|
94e3aae57d | ||
|
|
0a039239e4 | ||
|
|
67eb5b43eb | ||
|
|
4a4720cb62 |
@@ -68,6 +68,9 @@ design-explorations/
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
# Machine-local working files (never meant for git). ANCHORED so only the root
|
||||
# dir matches.
|
||||
/pr/
|
||||
# Root `public` (a symlink to scripts/remotion/public — local artifact). ANCHORED
|
||||
# with a leading slash so it does NOT also match src/web/public (a bare `public`
|
||||
# would swallow the whole web UI source dir and silently un-stage any new asset
|
||||
|
||||
+153
@@ -1,5 +1,158 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(history): Past Sessions data quality, and gate the phone run picker on CLI availability
|
||||
|
||||
**Past Sessions data quality (#215).** Three bugs in the transcript scanner behind
|
||||
the Cmd+K Session Manager and the phone overview's PAST SESSIONS list:
|
||||
- Automated/SDK-driven transcripts (CI review bots and other tooling, which Claude
|
||||
Code stamps with a non-`cli` `entrypoint`) were listed alongside real interactive
|
||||
sessions even though they were never resumable. They are now excluded. Detection
|
||||
scans every entrypoint-bearing message rather than stopping at the first, so a
|
||||
transcript that began under an older Claude Code build and only later picked up a
|
||||
non-`cli` entrypoint is no longer wrongly hidden.
|
||||
- A resumed session could show a same-directory sibling's preview text as its own.
|
||||
The `workingDir` backfill in `mergeUnifiedSessions()` now only ever applies to rows
|
||||
that have no history entry of their own, so it can no longer overwrite a row's real
|
||||
content with another conversation's.
|
||||
- Sessions restarted many times accumulated enough bookkeeping lines to push the real
|
||||
first prompt past the scanner's 16KB head-read window, leaving a blank row. The read
|
||||
is now two-tier: 16KB first, escalating to 128KB only when that was not enough, which
|
||||
is both correct and cheaper than reading 128KB unconditionally (measured on a real
|
||||
transcript tree: 36% fewer bytes read, roughly 17.5% faster than the unconditional
|
||||
version). Also restores the tail-read fallback for a file whose head read failed
|
||||
outright (for example `EMFILE` while scanning hundreds of files), which had been
|
||||
silently dropping the session from history.
|
||||
|
||||
Follow-up hardening on top of the above: the automated-transcript exclusion now
|
||||
blocklists the SDK entrypoint shape (`sdk`, `sdk-cli`, `sdk-py`) instead of allowlisting
|
||||
the exact value `cli`. Because the check hides rows, an allowlist failed closed on any
|
||||
value Claude Code has not shipped yet: a future rename of the interactive entrypoint,
|
||||
or a second interactive host, would have blanked the entire Past Sessions list with
|
||||
nothing in the UI to explain it. An unrecognized automated entrypoint now costs a few
|
||||
noisy rows instead, which is the annoyance this filter set out to fix rather than a
|
||||
broken feature.
|
||||
|
||||
**Phone overview run picker (#214).** The "C" logo home screen's Run picker listed all
|
||||
six backends regardless of what was installed, so tapping an uninstalled one produced a
|
||||
failed launch instead of the entry simply not being offered. It is now gated on
|
||||
`isCliAvailable()` exactly like the desktop toolbar's run-mode dropdown (shell exempt,
|
||||
since it has no external CLI dependency and keeps the menu from ever being empty). The
|
||||
picker is a hardcoded duplicate of the toolbar menu rather than a shared render, which
|
||||
is why it never picked up the earlier gating work; a test now asserts that every mode
|
||||
the picker offers is gated, so a newly added backend cannot silently drift again.
|
||||
|
||||
- 73315bc: fix(web): stop the Claude response viewer from following another session's conversation
|
||||
|
||||
The viewer re-derived a pane's live conversation by taking the newest
|
||||
`~/.claude/history.jsonl` entry for the pane's cwd. A cwd is shared with every
|
||||
other Codeman tab on it, with tabs long since closed, and with any plain
|
||||
`claude` run in the user's own terminal, so the eye followed whichever of those
|
||||
was typed into last — and the adoption was written back to the session, so the
|
||||
mispin persisted. Entries are now credited to a pane only when they land within
|
||||
10s of that pane's own Enter and no other pane on the cwd submitted closer, the
|
||||
same last-submit correlation the Codex locator already uses.
|
||||
|
||||
That correlation also has to survive a restart. `start()` resets
|
||||
`claudeSessionId` to the launch id even when re-attaching to a mux session whose
|
||||
CLI has since moved on via `/clear`, so a recovered pane pointed the viewer at
|
||||
its pre-`/clear` transcript — and with the anchor itself living only in memory,
|
||||
nothing corrected it until the user happened to type again. `lastSubmitAt` is
|
||||
now persisted in `SessionState` and restored on boot recovery, so the viewer
|
||||
re-derives the live conversation on its first poll.
|
||||
|
||||
## 1.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Two user-facing features since 1.10.0.
|
||||
|
||||
**Terminal: Ctrl+C copies the selection, interrupts when nothing is selected** (#211). Copying from the terminal previously worked only through the browser context menu: xterm turns Ctrl+C into 0x03 and cancels the keydown, so the muscle-memory copy failed silently and read as "no copy-paste at all". With a selection, Ctrl+C now copies it, shows the "Copied to clipboard" toast, clears the selection and sends nothing to the PTY; with no selection it falls through unchanged, so the interrupt is intact. Ctrl+Shift+C is an explicit copy chord that never interrupts. The shortcut is a normal registry entry (`copy-selection`), so it can be rebound or disabled in App Settings, and disabling it restores plain always-interrupt Ctrl+C. Copy goes through the Clipboard API with a hidden-textarea fallback, so it also works on plain-HTTP LAN installs.
|
||||
|
||||
**File Viewer: edit mode for text files** (#212). The file-preview overlay can now edit workspace text files in place, phone-first: `GET /api/sessions/:id/file-content?edit=1` reads for edit without the 500-line preview truncation (saving a truncated buffer would silently delete the rest) and returns a sha256 hash plus the detected EOL; `PUT /api/sessions/:id/file-content` saves. Edit-in-place only: there is no O_CREAT anywhere in the handler, so "never create, never delete" is structural. Confinement inherits the read path (realpath plus workspace boundary, ownership scoping) and adds sensitive-path and attachment-guard blocklists, a `.git/` subtree deny, and an extension allowlist (`svg` and `env` deliberately excluded). Optimistic concurrency is by content hash, so a file changed on disk mid-edit returns 409 with an overwrite option rather than clobbering. Writes are atomic (`wx` temp, fchmod, fsync, rename) which closes the validate-then-write TOCTOU window and cannot follow a pre-existing symlink. Binary and latin-1 content are refused via a NUL sniff plus a UTF-8 round-trip compare, and EOL is re-applied server-side so a textarea's LF normalization cannot turn a two-line edit of a CRLF file into a whole-file diff.
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Codeman 1.10.0.
|
||||
|
||||
**Every surface that offers a CLI now checks the CLI is actually there** (#200, #201). The welcome-screen run buttons, the run-mode dropdown and the App Settings "Codex CLI" tab used to be shown unconditionally, so picking one on a box without the binary spawned a session that errored out immediately. All of them now gate on a single server-injected availability object covering Claude, OpenCode, Codex, Gemini, Antigravity and cloudflared, so nothing flickers in after paint and the dropdown costs no round trips to open. Shell is never gated, which is what keeps the menu non-empty on a box with nothing installed, and unknown availability reads as available so a stale page can never leave a working install with nothing to click. Adds `isClaudeAvailable()` and `GET /api/claude/status`, the one CLI that had no availability check despite being the default. The Cloudflare Tunnel welcome button and its scan-to-connect QR are gated on `cloudflared` rather than shown regardless.
|
||||
|
||||
**Shell and remote-SSH sessions now launch a real login shell** (#209, #210). Local shell tabs match what tmux itself does for a pane with no `default-command`, picking up the `/etc/profile` and `/etc/profile.d/*` entries a systemd `--user` service never sourced. On remote SSH, `claude`/`opencode`/`codex`/`gemini`/`agy` are routed through the remote user's interactive login shell, fixing agent CLIs that silently failed with "command not found" because ssh's remote-command execution sees only sshd's minimal default PATH and not the `~/.local/bin` or `~/.opencode/bin` entries where those CLIs actually live. Shell mode uses the remote user's real shell instead of hardcoded bash. The login flags are applied only to shells verified to accept them, so an exotic passwd entry (nushell, elvish, xonsh) cannot produce a dead pane on arrival.
|
||||
|
||||
**A crashed remote pane is kept for diagnosis** (#210), which is how the PATH failure above was found: it previously destroyed the pane, the window and the whole remote session on exit, tearing the local ssh attach down with it and leaving a flap loop with no evidence. Scoped to `remain-on-exit failed`, so a clean `exit` still tears the session down and only a non-zero exit strands anything, and applied last in the tmux command chain so a remote tmux older than 3.2 cannot drop the other session options with it.
|
||||
|
||||
**Resumed sessions under a hidden directory get the right working directory** (#202). Claude Code's project-key encoder maps both `/` and `.` to `-`, and the decoder could not reconstruct a dot-prefixed component, so every session under `~/.codeman` (or any project nested beneath any dotdir) silently resolved to bare `$HOME`. The wrong `workingDir` then propagated into `state.json` and everything trusting it: CLAUDE.md lookup, paste-image directory, subagent and image watchers. A same-named non-dot sibling could also produce a doubled-slash path that failed every later string comparison.
|
||||
|
||||
**Launching a session no longer wipes the terminal you are looking at** (#180). All six run modes route through the shared ownership helpers instead of clearing and writing into whatever session happened to be active, Antigravity included.
|
||||
|
||||
**Codex terminal animations are configurable** (#181), and the App Settings "Codex CLI" tab appears only where the `codex` binary resolves, since both settings on it are handed to `codex` at launch.
|
||||
|
||||
## 1.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two bug fixes.
|
||||
|
||||
**Plain shell sessions could not start when the server process had no `SHELL` (#208).** The tmux pane command for `mode: 'shell'` was the literal string `$SHELL`. That string is embedded in the `bash -c "..."` argument of the `respawn-pane` line, which is run through `/bin/sh -c`, so it was expanded by the _server_ process's shell against the _server_ process's environment rather than inside the pane. Containers and system-level systemd units do not set `SHELL`, so it expanded to nothing and the pane command ended in a dangling `&&`, giving `bash: -c: line 1: syntax error: unexpected end of file` and a pane that died instantly (status 2) while tmux session creation still reported success. The shell is now resolved in Node (`$SHELL`, then the passwd entry, then `/bin/bash`, `/bin/zsh`, `/bin/sh`), requiring an absolute path to an executable and skipping `nologin`-style stubs, then shell-quoted. Only local shell sessions were affected: agent CLI modes emit a real command, and Docker/remote-SSH cases already used a literal `exec bash -l`.
|
||||
|
||||
**A session name typed into the tab options could be silently dropped.** Two independent paths. In the Session Options modal, the Session Name input saves on blur while every autosave handler bails on a null `editingSessionId`, and `closeSessionOptions()` cleared that id before hiding the modal (hiding is what blurs the input), so the save always ran too late; Escape and backdrop-click lost the name with no PUT at all, and only the X button worked because mousedown blurs first. The focused modal field is now blurred before the id is cleared, which also covers the auto-compact prompt. Separately, the right-click inline rename could be destroyed mid-keystroke: the `_inlineRenameActive` guard was missing from `_renderSessionTabsImmediate()`, so a render queued just before the rename opened still rewrote the tab name's innerHTML, committing a truncated name or closing the rename outright. The debounced executor is now guarded too.
|
||||
|
||||
## 1.9.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Fixed: sessions failed to start on macOS with `Error: posix_spawnp failed.`** (issues #6 and #204)
|
||||
|
||||
`node-pty@1.1.0` publishes its macOS prebuilt helper as `prebuilds/darwin-<arch>/spawn-helper` with mode 0644, i.e. no execute bit. macOS launches every PTY through that helper, so a stock install failed on every session start. The bug is macOS-only: `spawn-helper` is a mac-only gyp target and node-pty ships no Linux prebuild, so Linux always compiles a correctly-permissioned helper from source.
|
||||
|
||||
The previous fix chmodded only `build/Release/spawn-helper`, which on macOS does not exist (the prebuild is used, so node-gyp never runs), and it derived that path from `require.resolve('node-pty')`, landing on `<pkg>/lib/build/Release/...`. It was a no-op on every platform.
|
||||
- New `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every `spawn-helper` it finds, in `build/Release`, `build/Debug` and each `prebuilds/*/`, then verifies the result by actually opening a PTY. A `require()` alone passes on a broken install, because the helper is only touched at spawn time.
|
||||
- `postinstall` no longer force-rebuilds node-pty from source on Node 22+. That step needed Xcode command line tools, cost 30-120s on every install, and deleted the `prebuilds/` tree before compiling, so a Mac without a compiler was left with no working binary at all. A rebuild now happens only when the chmod plus spawn probe still fails, and the prebuilds tree is backed up and restored around it.
|
||||
- New `spawnPtyWithHelperRepair()` (`src/utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts`, so an install that is already broken repairs itself on the first failed spawn and retries in-process instead of showing a dead session. Unrelated spawn errors are rethrown untouched; a second failure carries the `npm run fix:node-pty` hint.
|
||||
- `scripts/fix-node-pty.mjs` is now in the published `files` list, so global npm installs get the repair too.
|
||||
- Direct-PTY Claude spawns use the resolved absolute binary path (new `getClaudeBinaryPath()`) instead of the bare name `claude`, so a CLI installed outside the server's PATH still launches.
|
||||
|
||||
Verified end to end on macOS 26.4 arm64: a stock `npm i` reproduces `posix_spawnp failed.`, and after the fix the same install spawns a PTY successfully with the prebuilds preserved.
|
||||
|
||||
**Added: phone home screen (session overview)**
|
||||
|
||||
Under 430px the "C" logo now opens a session overview (current sessions, past sessions, spaces) instead of the welcome overlay: on a small screen "which session needs me" beats "how do I start one". Rows resume a session in place, and "New session here" goes through the normal quick-start path so remote and Docker cases keep their routing. Per-device setting `mobileOverviewEnabled` (phones only, default ON) in App Settings. Tablet and desktop are unchanged.
|
||||
|
||||
**Added: guided Tailscale setup in `install.sh`**
|
||||
|
||||
The network-access prompt is now 3-way: Tailscale, LAN, or local-only. The Tailscale path binds loopback and walks through installing Tailscale, logging in, the operator grant, the tailnet HTTPS-certificates toggle, and `tailscale serve --bg <port>`, then verifies the result end to end with curl. That gives HTTPS on a real certificate with no app password and no `0.0.0.0` bind, which is also what PWA install and web push need. `install.sh tailscale` retrofits it onto an existing install, and `CODEMAN_TAILSCALE=1` presets the choice. Serve state is detected from `tailscale serve status --json`; the installer never runs `tailscale serve reset` and never touches serve mappings other than 443 to Codeman's port. README and `docs/security-architecture.md` updated to match.
|
||||
|
||||
**Docs**: replaced a real tailnet hostname with placeholders in `docs/web-tabs-fixes-plan.md`.
|
||||
|
||||
**xterm-zerolag-input**: npm description and keywords only, no code change.
|
||||
|
||||
## 1.9.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Antigravity run mode, plus opt-in entrance animations.
|
||||
|
||||
**Antigravity CLI backend (#207).** Antigravity (`agy`) joins Claude Code, shell, OpenCode, Codex and Gemini as a sixth session backend, following the same pluggable-resolver pattern: `utils/antigravity-cli-resolver.ts` resolves the CLI and `GET /api/antigravity/status` reports availability and path. `ANTIGRAVITY_*` is added to the `ALLOWED_ENV_PREFIXES` allowlist so env overrides stay CLI-scoped rather than blanket-forwarded. Like the other external CLIs it requires tmux with no direct PTY fallback, because secrets are injected through socket-scoped `tmux setenv` and never on the spawn command line. The UI gains a Run-dropdown entry, an agent-type option, an `ag` tab badge and toolbar colours; `runAntigravity()` routes remote and docker cases through `POST /api/quick-start` and skips the local status probe for them.
|
||||
|
||||
**Entrance animations (opt-in, OFF by default).** Optional animations for the four things that appear when work starts: session tabs, the terminal pane a session's CLI runs in, floating agent windows, and the connection lines tying a window back to its parent tab. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Choose a look in App Settings > Appearance > Entrance Animations (per-device, stored in localStorage rather than the settings payload); `?animlab=1` opens a per-surface picker with a live preview that fakes tabs, a pane, a window and a line so styles can be compared without spawning sessions.
|
||||
|
||||
Three implementation notes worth knowing if you touch this: tabs and connection lines are destroyed mid-animation on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML, `_updateConnectionLinesImmediate()` clears the SVG), so both are tracked by id and re-applied to the fresh element with a negative `animation-delay` that resumes rather than restarts them; terminal-pane styles animate transform, opacity and clip-path only, because xterm's FitAddon derives rows and columns from the untransformed layout box and animating width or height there would resize the PTY; and window styles that transform also move the rect their connection line aims at, which is why the `beam` style animates opacity and filter only.
|
||||
|
||||
Also fixes an agent window spawning hidden (its agent belongs to a background tab): being `display:none` it never ran its animation, so `animationend` never fired and the entrance class plus its inline custom property stuck to the window permanently. Hidden windows now skip the entrance entirely.
|
||||
|
||||
## 1.9.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two fixes from community PRs (thanks @Lint111):
|
||||
- fix(transcripts): complete tools from user-entry results (#177). Claude transcripts record tool requests in assistant entries but commonly carry their results in user-role entries; the transcript watcher only completed tools from the older assistant-entry path, so Codeman could keep showing a tool as running after it had finished. The watcher now recognizes `tool_result` blocks in user entries, ends the active tool state, and emits `transcript:tool_end` with the correct tool name and error status. Watcher tests also moved from fixed sleeps to condition-based `vi.waitFor` assertions.
|
||||
- fix(notifications): quiet lifecycle hook noise (#178). Notification preferences move to schema version 5: the drawer-only "Response complete" (stop) default is now off, and the migration disables only the legacy drawer-only shape, preserving any explicit browser, audio, or push delivery the user opted into. Teammate-idle and task-completed hooks now map to the existing opt-in subagent categories instead of the broadly enabled idle/stop alerts, so normal agent activity no longer floods the drawer. Local and server-hydrated preferences are normalized through the same migration path (server hydration used to revive the retired default on fresh browsers), and the notification storage key now uses the stable handheld identity so an unfolded foldable keeps its mobile defaults and storage key (tablets and desktops unaffected).
|
||||
|
||||
## 1.9.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,13 +74,13 @@ 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.9.5 (must match `package.json`)
|
||||
**Version**: 1.11.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
||||
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), and Gemini (Google) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'`).
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), and Antigravity (`agy`, Google) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity'`).
|
||||
|
||||
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
||||
|
||||
@@ -122,14 +122,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **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_*` env vars** — 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.)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` env vars** — 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.)
|
||||
- **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
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. 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). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md`
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. 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). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-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
|
||||
- **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
@@ -157,7 +158,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 23 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 25 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
@@ -193,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All three **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
||||
|
||||
**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 sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
@@ -213,6 +214,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**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`
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
@@ -229,9 +232,13 @@ 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) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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).
|
||||
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) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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).
|
||||
|
||||
**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 (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||
|
||||
**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 (0x0B) 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`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [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.
|
||||
|
||||
@@ -255,7 +262,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -272,7 +279,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
|
||||
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
||||
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
|
||||
@@ -352,6 +359,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
|
||||
|
||||
## Scripts & Tunnel
|
||||
|
||||
**`install.sh`** (repo root, 69KB) is the public entry point: `curl -fsSL <raw url> | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. It prompts for the network binding (LAN default + password prompt) and preserves the existing binding on re-runs via `read_existing_binding()`. `install.sh update` and `install.sh uninstall` also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation.
|
||||
**`install.sh`** (repo root, 69KB) is the public entry point: `curl -fsSL <raw url> | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: **Tailscale** (loopback bind + guided `tailscale serve --bg <port>` HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), **LAN** (0.0.0.0 + password prompt), or **local-only**; it preserves the existing binding on re-runs via `read_existing_binding()`. Tailscale state is detected dynamically from `tailscale serve status --json` (no marker files); the installer must NEVER `tailscale serve reset` or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). `install.sh update`, `install.sh uninstall`, and `install.sh tailscale` (retrofit Tailscale access onto an existing install) also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation, `CODEMAN_TAILSCALE=1` presets the Tailscale choice (never installs Tailscale non-interactively).
|
||||
|
||||
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
|
||||
@@ -197,7 +197,7 @@ codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended) — it provides a private network so you can access `http://<tailscale-ip>:3000` from your phone without TLS certificates.
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone.
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
@@ -651,6 +651,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+C` | Copy selection, or interrupt when nothing is selected |
|
||||
| `Ctrl+Shift+C` | Copy selection (never interrupts) |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
|
||||
@@ -641,6 +641,8 @@ sc -l # 列出会话
|
||||
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
||||
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||
| `Ctrl/Cmd+C` | 复制选中内容;未选中时中断代理 |
|
||||
| `Ctrl+Shift+C` | 复制选中内容(永不中断) |
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
|
||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
||||
'test/inline-rename.test.ts', // browser (Playwright)
|
||||
'test/opencode-resize.test.ts', // browser (Playwright)
|
||||
'test/webgl-fallback.test.ts', // browser (Playwright)
|
||||
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
|
||||
],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
fileParallelism: false,
|
||||
|
||||
@@ -87,6 +87,19 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`.
|
||||
|
||||
### File Viewer edit mode
|
||||
|
||||
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes**, so it carries every rule the read surfaces have plus its own:
|
||||
|
||||
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
|
||||
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
|
||||
- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick.
|
||||
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
|
||||
- **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff).
|
||||
- **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all.
|
||||
|
||||
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
|
||||
|
||||
### Ultracode and workflow-run visualization
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
@@ -138,6 +151,14 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
||||
|
||||
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
|
||||
|
||||
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
|
||||
|
||||
1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top).
|
||||
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
|
||||
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
|
||||
|
||||
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
||||
|
||||
### WebGL renderer toggle
|
||||
|
||||
**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
|
||||
@@ -147,6 +168,10 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
||||
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
|
||||
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`.
|
||||
|
||||
⚠️ **A Claude pane's conversation is identified by the pane's own Enter, never by "newest entry for this cwd".** `~/.claude/history.jsonl` records every submitted prompt as `{project, sessionId, timestamp}`, and `/clear` moves the pane to a fresh `<uuid>.jsonl` that nothing on the PTY announces — so the viewer has to re-derive the live conversation. Keying that off `project` alone was the bug: a cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain `claude` the user runs in their own terminal, so the eye followed whichever of those conversations was typed into last and showed a stranger's transcript. `resolveActiveClaudeSessionIdFromHistory()` instead credits an entry to a pane only when it lands within `CLAUDE_SUBMIT_MATCH_MS` of that pane's `Session.lastSubmitAt` **and** no other pane on the same cwd submitted closer — the same last-submit correlation the Codex locator uses. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation.
|
||||
|
||||
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
|
||||
|
||||
⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.
|
||||
|
||||
@@ -206,6 +231,16 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
**`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs` (the `xterm-zerolag-input` esbuild step, for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||
|
||||
### node-pty's macOS spawn-helper must be executable
|
||||
|
||||
**node-pty ships its macOS `spawn-helper` non-executable, which breaks every session start on macOS** (issues #6 and #204, fixed properly in 1.9.8). `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` with mode **0644**. On macOS node-pty launches every PTY through that helper (`argv[0] = helper_path` → `pty_posix_spawn()` in `src/unix/pty.cc`, guarded by `#if defined(__APPLE__)`), so a helper without `+x` makes `posix_spawnp` fail EACCES and node-pty throws `Error: posix_spawnp failed.`, surfaced by Codeman as "Failed to start Claude: …". It is **macOS-exclusive twice over**: `spawn-helper` is an `OS=="mac"` gyp target, and node-pty ships prebuilds for darwin + win32 only, so Linux always compiles from source (node-gyp emits an executable helper) and can never reproduce it. That asymmetry is why it kept coming back: a Linux dev box shows nothing wrong.
|
||||
|
||||
⚠️ **Look in `prebuilds/<platform>-<arch>/`, not just `build/Release/`.** node-pty's loader (`lib/utils.js`) tries `build/Release`, `build/Debug`, then `prebuilds/<platform>-<arch>`, and `unixTerminal.js` derives `helperPath` from **whichever directory the native module loaded out of**. The 0.15 fix only chmodded `build/Release/spawn-helper`, which on macOS does not exist at all (the prebuild is used, so node-gyp never runs), and it resolved the path off `require.resolve('node-pty')` (= `<pkg>/lib/index.js`), producing `<pkg>/lib/build/Release/spawn-helper`, so it was a no-op on every platform.
|
||||
|
||||
The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only *then* shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install).
|
||||
|
||||
`src/utils/node-pty-repair.ts` is the runtime safety net for installs that are already broken: all four `pty.spawn()` calls in `session.ts` go through `spawnPtyWithHelperRepair()`, which chmods and retries **once** on a `posix_spawnp`/`spawn-helper` error and rethrows anything else untouched, so the user never sees a dead session. A second failure rethrows with the `npm run fix:node-pty` hint attached instead of a bare "posix_spawnp failed". The repair is attempted at most once per process (no chmod storms).
|
||||
|
||||
## Tooling traps
|
||||
|
||||
### Headless screenshot capture
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# File Viewer edit mode (issue #212)
|
||||
|
||||
Plan only. No implementation yet.
|
||||
|
||||
Goal: close the loop "agent writes a file, you review it in the viewer, tweak two lines, save, tell the
|
||||
agent to continue" without hopping into the terminal, with the phone as the primary target.
|
||||
|
||||
Scope from the issue: an Edit toggle on text previews, a write endpoint that inherits the read path's
|
||||
confinement, text-only, edit-in-place (no create, no delete, no rename), no editing through the
|
||||
Docker/remote overlays.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today
|
||||
|
||||
**Read path (backend), all in `src/web/routes/file-routes.ts`:**
|
||||
|
||||
| Route | Line | Notes |
|
||||
| ------------------------------------ | ------ | ------------------------------------------------------------------ |
|
||||
| `GET /api/sessions/:id/files` | `741` | Tree scan of `session.workingDir`, hidden files off by default |
|
||||
| `GET /api/sessions/:id/file-content` | `865` | The text/preview classifier. `findSessionOrFail` + `validateSessionFilePath` |
|
||||
| `GET /api/sessions/:id/file-raw` | `1018` | Bytes, 50MB cap |
|
||||
| `GET /api/sessions/:id/file-preview` | `1254` | DOCX/PPTX to PDF, everything else redirects to `file-raw` |
|
||||
| `GET /api/download` | `1384` | The only read route that also runs `isSensitivePath()` |
|
||||
|
||||
`file-content` classification order (`file-routes.ts:881-1011`): extension buckets (image / video / audio /
|
||||
known-binary) return metadata only; otherwise the bytes are read, sniffed for a NUL in the first 8KB, and
|
||||
either reported as `type:'binary'` or decoded as UTF-8 and **truncated to `lines` (default 500, hard cap
|
||||
10000)**. Caps: `MAX_TEXT_FILE_SIZE` 10MB.
|
||||
|
||||
Confinement is `validateSessionFilePath()` (`src/web/route-helpers.ts:67`): `resolve()` then `realpathSync()`
|
||||
then reject if the result is not under `workingDir`. Because it realpaths the *full* path, a symlink whose
|
||||
target escapes the workspace is already rejected. Ownership is `findSessionOrFail()` which runs
|
||||
`canAccessOwned()` (`route-helpers.ts:102`), a no-op outside multi-user mode.
|
||||
|
||||
**Read path (frontend), `src/web/public/panels-ui.js`:**
|
||||
|
||||
- `loadFileBrowser()` `2947`, `renderFileBrowserTree()` `2978`, click to `openFilePreview()` `3056`.
|
||||
- `openFilePreview(filePath, sessionId, attachmentId)` `3193`: attachment-id branch, then docx/pptx, pdf,
|
||||
svg branches, then the generic `file-content` fetch at `3274` with **`&lines=500` hardcoded**, rendering
|
||||
text as `<pre><code>${escapeHtml(...)}</code></pre>` at `3298` and stashing `this.filePreviewContent`.
|
||||
- `closeFilePreview()` `3308`, `copyFilePreviewContent()` `3751`.
|
||||
- Markup: `src/web/public/index.html:420-432` (`filePreviewOverlay` / `-Title` / `-Body` / `-Footer`, two
|
||||
header buttons: copy and close).
|
||||
- CSS: `src/web/public/styles.css:9320-9430`. Overlay `z-index: 2000`, window `80vw/80vh`, capped
|
||||
`900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**.
|
||||
|
||||
**Reachability on phones.** The header File Viewer button is hidden below 430px
|
||||
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`), so on a
|
||||
phone the preview overlay is reached through:
|
||||
|
||||
1. an attachment card's **Preview** button (`panels-ui.js:3451`), which is exactly the "agent just wrote a
|
||||
file" path the issue describes,
|
||||
2. the attachment-history drawer (`panels-ui.js:3709`),
|
||||
3. App Settings to Panels to **File Browser** (`showFileBrowser`, applied in `settings-ui.js:2202`; the
|
||||
panel is mobile-styled at `mobile.css:1868`).
|
||||
|
||||
So edit mode is reachable on a phone today via (1) and (2) without touching the header policy. Improving
|
||||
the entry point is listed as an open decision in section 10, not assumed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat model, stated honestly
|
||||
|
||||
Anyone who can call this API can already reach `POST /api/sessions/:id/input` and type an arbitrary prompt
|
||||
into an agent running with `--dangerously-skip-permissions`. A workspace-confined write endpoint therefore
|
||||
does not create a new privilege tier for an authenticated caller.
|
||||
|
||||
What it *would* create if built carelessly is a **new host-write primitive reachable by path**, so the
|
||||
things this plan actually defends against are:
|
||||
|
||||
1. **Path traversal / symlink escape** writing outside the workspace.
|
||||
2. **TOCTOU**: a path component that becomes a symlink between validation and write.
|
||||
3. **Cross-user writes** in multi-user mode (`canAccessOwned`).
|
||||
4. **Silent data loss**, which is the highest-probability real-world failure here and gets its own section.
|
||||
|
||||
CSRF is already covered: `registerHostGuard()` (`src/web/middleware/auth.ts:555-578`) rejects any
|
||||
non-safe-method request whose `Origin` is cross-site. The webview-capability exemption at that gate is
|
||||
fenced to `GET`/`HEAD` for the Referer form (`auth.ts:161`) and to `/webview/:cap/*` paths for the path
|
||||
form, so a proxied dashboard cannot reach a new `PUT /api/...`. Using `PUT` + `application/json` also
|
||||
forces a preflight for any cross-origin attempt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend design
|
||||
|
||||
### 3.1 New policy module: `src/config/file-editing.ts`
|
||||
|
||||
Pure, unit-testable, no IO (config lives in `src/config/`, no barrel, import the file directly).
|
||||
|
||||
```ts
|
||||
export const MAX_EDITABLE_BYTES = 512 * 1024; // content cap, both directions
|
||||
export const EDITABLE_EXTENSIONS: ReadonlySet<string>; // ts,tsx,js,jsx,mjs,cjs,json,jsonc,md,mdx,txt,
|
||||
// css,scss,less,html,htm,xml,svg?,yml,yaml,toml,
|
||||
// ini,cfg,conf,env?,sh,bash,zsh,fish,py,rb,go,rs,
|
||||
// java,kt,swift,c,h,cpp,hpp,cs,php,sql,graphql,
|
||||
// proto,lua,pl,r,jl,tf,gradle,csv,tsv,log,diff,patch
|
||||
export const EDITABLE_BASENAMES: ReadonlySet<string>; // Dockerfile, Makefile, LICENSE, .gitignore,
|
||||
// .prettierignore, .editorconfig, .nvmrc, ...
|
||||
export function isEditableFileName(fileName: string): boolean;
|
||||
export function isDeniedEditRelativePath(rel: string): boolean; // `.git/` subtree
|
||||
export function detectEol(text: string): 'lf' | 'crlf';
|
||||
export function applyEol(text: string, eol: 'lf' | 'crlf'): string;
|
||||
```
|
||||
|
||||
Decisions baked in:
|
||||
|
||||
- **Allowlist, not blocklist**, per the issue and per the existing attachment-guard precedent.
|
||||
- `svg` and `env` are deliberately marked with `?` above: `svg` is served as an untrusted octet-stream on
|
||||
the read side (`file-routes.ts:118`) so allowing an edit is defensible, but I recommend **excluding
|
||||
both** in v1. `.env` files are matched by `isSensitivePath()` anyway and would be rejected downstream;
|
||||
excluding them at the allowlist keeps a single obvious refusal.
|
||||
- `isDeniedEditRelativePath` blocks the `.git/` subtree: `.git/hooks/*` is code execution and a corrupt
|
||||
index is unrecoverable-looking to a user who only wanted to fix a typo. Other dotfiles stay allowed but
|
||||
are not reachable from the tree UI anyway (`showHidden=false`).
|
||||
|
||||
### 3.2 Read-for-edit: extend the existing GET
|
||||
|
||||
`GET /api/sessions/:id/file-content?path=<rel>&edit=1`
|
||||
|
||||
When `edit=1`:
|
||||
|
||||
- skip line truncation entirely (a truncated buffer must never become an edit buffer, see section 4.1),
|
||||
- enforce `MAX_EDITABLE_BYTES` instead of `MAX_TEXT_FILE_SIZE` and answer 413 over it (as a structured
|
||||
throw with `statusCode: 413`, the `throwFilesystemPickerError` pattern, since the central errorCode-to-
|
||||
status map has no 413 entry; see the error-mechanics note in 3.3),
|
||||
- run the editability gate (`isEditableFileName`, `isDeniedEditRelativePath`, `isSensitivePath`,
|
||||
`isBlockedAttachmentPath`) and the content gate (NUL sniff plus UTF-8 round-trip, see 4.3),
|
||||
- return `{ content, size, mtimeMs, totalLines, truncated: false, extension, editable: true, hash, eol }`.
|
||||
`hash` is `sha256` hex of the exact on-disk bytes.
|
||||
|
||||
Non-`edit` responses gain **only** `editable: boolean` (additive, no shape change for existing consumers),
|
||||
which is all the UI needs to decide whether to show the Edit button. No `hash` on plain reads: the Edit
|
||||
action re-fetches with `edit=1` anyway (section 4.1), which is where the hash comes from, and hashing every
|
||||
casual 10MB preview would be pure waste.
|
||||
|
||||
### 3.3 Write: `PUT /api/sessions/:id/file-content`
|
||||
|
||||
Body (new `FileWriteSchema` in `src/web/schemas.ts`, Zod v4):
|
||||
|
||||
```ts
|
||||
{ path: string, content: string, baseHash: string, eol?: 'lf'|'crlf', force?: boolean }
|
||||
```
|
||||
|
||||
Registered with an explicit route option `{ bodyLimit: 4 * 1024 * 1024 }`. **Fastify's default `bodyLimit`
|
||||
is 1MB and this repo configures none**, and JSON escaping expands content: 2x for a file full of quotes or
|
||||
backslashes, up to 6x for control characters (each serialized as a `\uXXXX` escape), so 512KB of content
|
||||
can legitimately exceed 1MB on the wire; blowing the limit produces a raw `FST_ERR_CTP_BODY_TOO_LARGE`, not an `ApiResponse` envelope. Two
|
||||
related sizing notes: `z.string().max()` counts **UTF-16 code units, not bytes**, so the schema's `.max()`
|
||||
is only a coarse pre-filter and the real cap is an explicit `Buffer.byteLength(content, 'utf8')` check in
|
||||
the handler (step 7a below); and 4MB comfortably bounds the worst-case expansion of a 512KB file without
|
||||
inviting multi-MB bodies elsewhere.
|
||||
|
||||
**Error mechanics** (matters for both prod behavior and testability): a handler that *returns* a
|
||||
`{success:false, errorCode}` envelope gets its HTTP status assigned centrally by the preSerialization hook
|
||||
in `server.ts` (`httpStatusForErrorCode()`, `src/types/api.ts`), but the route-test harness
|
||||
(`test/routes/_route-test-utils.ts`) installs only `installRouteErrorHandler`, **not** that hook, so
|
||||
returned envelopes surface as HTTP 200 in tests. The PUT handler should therefore use the same
|
||||
structured-**throw** pattern as the filesystem picker (`throwFilesystemPickerError`, `file-routes.ts:411`):
|
||||
thrown `{statusCode, body}` errors are rendered identically in prod and in the harness, and they allow the
|
||||
one status the code map cannot express (413). The error envelope itself is strictly
|
||||
`{success:false, error, errorCode}`, **it has no data arm**, so no error response may carry extra payload.
|
||||
|
||||
Handler order (each step is a test case):
|
||||
|
||||
1. `findSessionOrFail(ctx, id, req)` (live sessions only, matching the read route, and it carries the
|
||||
multi-user ownership check).
|
||||
2. `parseBody(FileWriteSchema, req.body)`, then `Buffer.byteLength(content, 'utf8') <= MAX_EDITABLE_BYTES`
|
||||
or 413 (the schema `.max()` alone cannot enforce a byte cap, see the sizing note above).
|
||||
3. `validateSessionFilePath(session.workingDir, path)` or 404 (do not distinguish "outside workspace" from
|
||||
"missing", matching the read route).
|
||||
4. `isSensitivePath(resolvedPath) || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)` or 403.
|
||||
5. `isDeniedEditRelativePath(relativePath)` or 403.
|
||||
6. `isEditableFileName(basename(resolvedPath))` or 400.
|
||||
7. `stat`: must be `isFile()`, size within `MAX_EDITABLE_BYTES`, else 400/413. **No `O_CREAT` anywhere in
|
||||
this handler**, which is what enforces edit-in-place.
|
||||
8. Read current bytes, compute `hash`, run the NUL sniff and the UTF-8 round-trip check, else 400.
|
||||
9. `hash !== baseHash && !force` gives **409 CONFLICT** (`ApiErrorCode.CONFLICT`, plain envelope; the error
|
||||
arm carries no data, see the error-mechanics note). The client's conflict dialog gets fresh state by
|
||||
re-fetching `edit=1`, which it needs for its Reload action anyway.
|
||||
10. Build the output buffer: `applyEol(content, eol ?? detected-from-original)`; re-check
|
||||
`Buffer.byteLength` against the cap.
|
||||
11. Write atomically in the resolved parent directory:
|
||||
`fs.open(<dir>/.<name>.codeman-tmp-<rand>, 'wx', stat.mode & 0o777)`, then `fchmod(stat.mode & 0o777)`
|
||||
(open's mode argument is masked by the process umask, so the chmod is what actually preserves an
|
||||
unusual mode), write, `fsync`, close, `fs.rename(tmp, resolvedPath)`, unlink the temp on any failure.
|
||||
12. Re-stat, return `{ success: true, data: { path, size, mtimeMs, hash, totalLines } }`.
|
||||
|
||||
Why `O_EXCL` temp plus rename rather than truncate-in-place:
|
||||
|
||||
- `wx` cannot follow a pre-existing symlink, which closes the TOCTOU window from step 3 to step 11 without
|
||||
needing `O_NOFOLLOW` gymnastics.
|
||||
- `rename()` does not follow a symlink in the final component, so even if `resolvedPath` were swapped for a
|
||||
symlink after validation, the symlink itself is replaced and the swap target is untouched.
|
||||
- A crash mid-write leaves the original intact.
|
||||
|
||||
Caveat to document in the code comment: rename replaces the inode, so hardlinks to the file keep the old
|
||||
content. That is the same trade-off vim makes by default and is preferable to a truncate window here.
|
||||
|
||||
No SSE event in v1. Nothing else in the app needs to know: `image-watcher.ts` only reacts to
|
||||
`.png/.jpg/.jpeg/.gif/.webp/.bmp/.svg/.pdf/.docx/.pptx` adds (`image-watcher.ts:23-25`), none of which are
|
||||
editable text, and the temp filename does not match either.
|
||||
|
||||
---
|
||||
|
||||
## 4. The five traps
|
||||
|
||||
These are the parts that turn a "small write endpoint" into a bug report.
|
||||
|
||||
### 4.1 Truncation (the data-loss trap)
|
||||
|
||||
The frontend fetches `&lines=500` (`panels-ui.js:3274`). Saving that buffer back would **delete every line
|
||||
past 500**. Worse, the content hash of the full file would still match, so an optimistic-concurrency check
|
||||
cannot catch it.
|
||||
|
||||
Mitigations, all three:
|
||||
|
||||
- The Edit affordance is only offered when the loaded payload came from `edit=1` (which never truncates).
|
||||
Tapping Edit on an already-rendered preview **re-fetches** with `edit=1` before swapping in the editor.
|
||||
- The read-for-edit path 413s above `MAX_EDITABLE_BYTES` rather than truncating, so "too big to edit here"
|
||||
is an explicit refusal with a message, never a silent partial buffer.
|
||||
- A test asserts `edit=1` never returns `truncated: true`.
|
||||
|
||||
### 4.2 Line endings
|
||||
|
||||
A `<textarea>`'s `.value` normalizes to LF. Saving a CRLF file naively rewrites every line, producing a
|
||||
whole-file diff for a two-line change. So: the read returns the detected `eol`, the client echoes it back
|
||||
unchanged, and the server re-applies it. Mixed-EOL files use the dominant style, which is lossy for the
|
||||
minority lines; call that out in the response and accept it in v1.
|
||||
|
||||
### 4.3 Encoding
|
||||
|
||||
`buf.toString('utf-8')` on a latin-1 or otherwise non-UTF-8 file yields U+FFFD replacement characters, and
|
||||
writing that back **corrupts the file**. The check is a round-trip:
|
||||
`Buffer.from(decoded, 'utf8').equals(buf)`. If it fails, `editable: false` and the write is refused. This
|
||||
also catches binary content that the NUL sniff misses. A UTF-8 BOM survives because it round-trips as a
|
||||
leading U+FEFF; do not strip it.
|
||||
|
||||
### 4.4 Concurrency with the agent
|
||||
|
||||
The whole use case is editing a file the agent just wrote and may write again. `baseHash` plus 409 is the
|
||||
guard. Do not use mtime alone: agents rewrite files within a single filesystem timestamp tick, and an
|
||||
identical rewrite should not be reported as a conflict.
|
||||
|
||||
### 4.5 Symlinks and TOCTOU
|
||||
|
||||
Covered by `validateSessionFilePath` (escape) plus `wx` temp and `rename` (post-validation swap). One
|
||||
intentional allowance: a symlink whose target is *inside* the workspace is edited through to its target,
|
||||
because `validateSessionFilePath` returns the realpath. That matches what a user tapping the file expects.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend design
|
||||
|
||||
All in `panels-ui.js` (prettier-exempt, hand-formatted; match the surrounding style), `index.html`,
|
||||
`styles.css`, `mobile.css`.
|
||||
|
||||
### 5.1 State
|
||||
|
||||
```js
|
||||
filePreviewEdit = { active, sessionId, path, baseHash, eol, original, dirty }
|
||||
```
|
||||
|
||||
Reset in `closeFilePreview()` and on every `openFilePreview()` entry.
|
||||
|
||||
### 5.2 Markup (`index.html:420-432`)
|
||||
|
||||
Add one header button (pencil, `btn-icon-sm`, `id="filePreviewEditBtn"`, hidden by default) next to the
|
||||
copy button, and an edit bar inside the footer region holding Save / Cancel / a dirty dot. Keep the
|
||||
existing footer text element; the edit bar is a sibling toggled by class so the read-mode footer is
|
||||
untouched.
|
||||
|
||||
### 5.3 Behavior
|
||||
|
||||
- `openFilePreview()` shows the Edit button only when the response has `editable: true` and the render took
|
||||
the text branch. Attachment-id previews, media, binary, pdf, docx/pptx and svg all leave it hidden.
|
||||
- **Enter edit**: re-fetch with `edit=1`; on 413 or `editable:false`, toast the reason and stay in read
|
||||
mode. This fetch must **parse the error envelope on non-ok responses**: the existing generic
|
||||
`if (!res.ok) throw new Error('Failed to load file')` pattern (`panels-ui.js:3275`) would swallow the
|
||||
specific "too large to edit here" message, since error envelopes arrive with real 4xx statuses in prod. On success replace the body with `<textarea class="file-preview-editor" spellcheck="false"
|
||||
autocapitalize="off" autocorrect="off" autocomplete="off" wrap="off">` and assign `.value = content`
|
||||
(never `innerHTML`, so no escaping question arises). Do **not** autofocus: on a phone that opens the
|
||||
keyboard before the user has picked a line.
|
||||
- `input` sets `dirty` and enables Save.
|
||||
- **Save**: `PUT` with `baseHash`, `eol`, and `content`. On success update `baseHash`/`original` from the
|
||||
response, leave edit mode, re-render the read view from the local editor value (the response carries
|
||||
metadata only, not content), toast "Saved". On **409** offer `Reload (discard mine)` / `Overwrite`:
|
||||
Reload re-fetches `edit=1` and replaces the buffer; Overwrite re-sends with `force: true`. The 409 body
|
||||
itself carries no state (section 3.3, step 9).
|
||||
- **Cancel / close / Escape while dirty**: `confirm('Discard unsaved changes?')`, consistent with the
|
||||
existing `window.confirm` usage in this codebase (`panels-ui.js:4323`, `app.js:4176`). Note the global
|
||||
Escape handler (`app.js:999-1007`) closes other panels via `closeAllPanels()` but does not touch this
|
||||
overlay today; if Escape-to-close is wired up as part of this work it must go through the same dirty
|
||||
guard.
|
||||
- `copyFilePreviewContent()` copies the live editor value while editing.
|
||||
|
||||
⚠️ Repo gotcha to respect at the fetch call: **Zod `.optional()` rejects `null`**. Build the body with
|
||||
`eol: eol ?? undefined` (or declare `.nullish()`), or the PUT fails `INVALID_INPUT`. This has shipped as a
|
||||
real bug twice.
|
||||
|
||||
### 5.4 Mobile
|
||||
|
||||
- **Sizing.** The window is `80vw/80vh` centered with no mobile override, so when the keyboard opens on iOS
|
||||
the lower half sits behind it. Add a `@media (max-width: 430px)` block using
|
||||
`height: var(--app-height, 100vh)`, full width, no border radius. `--app-height` is already maintained
|
||||
against `visualViewport` by `KeyboardHandler.handleViewportResize()` (`mobile-handlers.js:283-317`), so
|
||||
the editor tracks the keyboard for free.
|
||||
- **iOS zoom.** The editor font must be >= 16px on phones; there is an existing zoom-prevention block at
|
||||
`mobile.css` under `@media (max-width: 768px)`. Verify it covers `textarea` and do not override it with a
|
||||
smaller `rem` value.
|
||||
- **Accessory bar.** Focusing any input fires `KeyboardHandler.onKeyboardShow()`, which calls
|
||||
`KeyboardAccessoryBar.show()` and refits/resizes the terminal (`mobile-handlers.js:407+`). The bar's keys
|
||||
target the **terminal**, not the editor, so an Esc or clear-input tap while editing goes to the agent.
|
||||
The overlay's `z-index: 2000` covers the bar's `51`, so it is not visible, but confirm it is not
|
||||
interactive underneath and consider an explicit `KeyboardAccessoryBar.hide()` while the editor holds
|
||||
focus. This is the item most likely to look "fine on desktop, wrong on the phone".
|
||||
- No header-policy change is needed (section 1), so
|
||||
`test/mobile-header-buttons-policy.test.ts` stays untouched.
|
||||
|
||||
### 5.5 i18n
|
||||
|
||||
`i18n.js` already skips `textarea`, `pre`, `code` and `.file-preview-content` in its `SKIP_SELECTOR`
|
||||
(`i18n.js:20-38`), so file content is never translated. Add zh-CN entries for the new chrome: Edit, Save,
|
||||
Cancel, Unsaved changes, Discard unsaved changes?, File changed on disk, Reload, Overwrite, Saved,
|
||||
Too large to edit here.
|
||||
|
||||
---
|
||||
|
||||
## 6. Docker and remote cases
|
||||
|
||||
Out of scope per the issue, and the current behavior already degrades correctly:
|
||||
|
||||
- **Docker cases**: the workspace is a host directory bind-mounted at the same absolute path, so a host-side
|
||||
write is visible in the container immediately. Edit mode works and needs nothing special. Worth one line
|
||||
in the docs.
|
||||
- **Remote SSH cases**: `workingDir` is a path on the remote host. `validateSessionFilePath` realpaths it
|
||||
locally, which fails, so the write returns 404 exactly like the read routes do today. Confirm the viewer
|
||||
shows a clean empty/error state rather than an unexplained failure, and do not attempt an SFTP path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests
|
||||
|
||||
| File | Kind | Covers |
|
||||
| ------------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| `test/file-editing-policy.test.ts` | pure unit | `isEditableFileName` (allow + deny + basenames), `isDeniedEditRelativePath`, `detectEol`/`applyEol` round-trip incl. mixed EOL, BOM preservation |
|
||||
| `test/routes/file-write-routes.test.ts` | `app.inject` | The handler order in 3.3, against a **real temp dir** (do not `vi.mock('node:fs')` in this file; set `MockSession.workingDir`, `test/mocks/mock-session.ts:14`) |
|
||||
| extend `test/routes/file-routes.test.ts` | `app.inject` | `edit=1` never truncates; `editable` present on the plain read |
|
||||
|
||||
Status-code caveat for all of these: the route-test harness does not install the server's preSerialization
|
||||
envelope hook, so a handler that *returns* an error envelope answers 200 in tests. The statuses below are
|
||||
only assertable because the plan has the handler **throw** structured errors (section 3.3, error
|
||||
mechanics), which `installRouteErrorHandler` renders identically in prod and in the harness.
|
||||
|
||||
Route cases to assert explicitly:
|
||||
|
||||
1. happy path writes the bytes and returns a new hash
|
||||
2. `../` and absolute paths give 404
|
||||
3. symlink pointing outside the workspace gives 404
|
||||
4. symlink pointing inside is written through to the target
|
||||
5. non-allowlisted extension gives 400
|
||||
6. `.git/config` gives 403
|
||||
7. a `.env` in the workspace gives 403 (sensitive-path)
|
||||
8. a file with a NUL byte gives 400
|
||||
9. a latin-1 file that fails the UTF-8 round-trip gives 400
|
||||
10. stale `baseHash` gives 409 (`CONFLICT` envelope, no data); `force:true` then succeeds
|
||||
11. over `MAX_EDITABLE_BYTES` gives 413
|
||||
12. a path that does not exist gives 404 and creates nothing (no `O_CREAT`)
|
||||
13. multi-user: `authUser: {role:'user'}` against another user's session gives 404 (pass `authUser` to
|
||||
`createRouteTestHarness`, otherwise the synthetic admin makes the test pass vacuously)
|
||||
14. CRLF file edited and saved stays CRLF
|
||||
15. file mode is preserved across the temp-plus-rename
|
||||
|
||||
Run with `npm test -- test/routes/file-write-routes.test.ts`, never bare `npm test`.
|
||||
|
||||
**End-to-end verification before any deploy** (unit tests passing is not sufficient here):
|
||||
|
||||
- `curl -sk https://localhost:3000/...` against a **throwaway** session created for the purpose, never
|
||||
`w1`/`w2`/`w3`; delete it by exact id afterwards.
|
||||
- Playwright on a phone profile: open a preview, tap Edit, type with `page.keyboard.type()`, Save, then
|
||||
assert the bytes on disk changed. Assert real state, not HTTP 200.
|
||||
|
||||
---
|
||||
|
||||
## 8. Docs and release
|
||||
|
||||
- This plan lives at `docs/file-viewer-edit-plan.md`.
|
||||
- `docs/architecture-invariants.md`: new anchor `#file-viewer-edit-mode` covering the write confinement
|
||||
chain, the truncation invariant, and why temp-plus-rename.
|
||||
- `CLAUDE.md`: one line under the **Filesystem path picker** neighborhood noting that the File Viewer now
|
||||
has a **third** file surface and that it is the only one that writes, plus its confinement rules.
|
||||
Remember `CLAUDE.md` is prettier-ignored on purpose.
|
||||
- `docs/api-reference.md`: the new `PUT` and the `edit=1` query.
|
||||
- Release: a normal COM applies (the 1.10.0 batch hold is over). This is a new user-facing feature plus an
|
||||
additive API surface, so **COM minor** when it ships.
|
||||
|
||||
Formatting note: `panels-ui.js`, `styles.css`, `mobile.css`, `index.html` are all in `.prettierignore` and
|
||||
are hand-formatted; new TypeScript (`src/config/file-editing.ts`, route + schema edits) is prettier-enforced
|
||||
and must pass `npm run format:check`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation order
|
||||
|
||||
Each phase is independently reviewable and leaves the tree working.
|
||||
|
||||
1. **Policy module + tests.** `src/config/file-editing.ts` and `test/file-editing-policy.test.ts`. Pure, no
|
||||
route wiring. (Small.)
|
||||
2. **Read-for-edit.** `edit=1` (returning `hash`/`eol`) plus the additive `editable` flag on plain reads,
|
||||
tests. Nothing consumes it yet. (Small.)
|
||||
3. **Write endpoint.** `FileWriteSchema`, `PUT` handler, `test/routes/file-write-routes.test.ts`. Fully
|
||||
testable by curl before any UI exists. (Medium, the security-relevant part.)
|
||||
4. **Desktop UI.** Edit button, textarea swap, Save/Cancel, dirty guard, 409 flow. (Medium.)
|
||||
5. **Mobile pass.** `mobile.css` sizing against `--app-height`, font size, accessory-bar interaction,
|
||||
real-device check. (Small but the part that decides whether the feature is actually usable.)
|
||||
6. **Docs, i18n strings, changeset.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Open decisions
|
||||
|
||||
1. **Editor widget.** Recommend a plain `<textarea>` for v1: zero dependencies, no CSP question, no bundle
|
||||
growth, and it is the only thing guaranteed to behave with the iOS keyboard. CodeMirror-light with
|
||||
syntax highlighting is a clean follow-up once the write path is proven. The issue allows either.
|
||||
2. **Phone entry point.** Edit mode is reachable on a phone through attachment cards and the history
|
||||
drawer without changing anything. A dedicated toolbar or overview affordance for "browse this session's
|
||||
files" would make it discoverable, but it is a separate UX change and would need a decision against the
|
||||
deliberately minimal phone header policy. Recommend deferring it and revisiting after the feature ships.
|
||||
3. **`svg` editability.** Recommend excluded in v1 (it is deliberately treated as untrusted on the read
|
||||
side). Easy to add later.
|
||||
4. **Create / delete / rename.** Explicitly out of scope per the issue. Note that keeping `O_CREAT` out of
|
||||
the handler is what makes that a structural property rather than a convention.
|
||||
@@ -249,16 +249,28 @@ Ordered most‑to‑least recommended:
|
||||
|
||||
### A. Tailscale serve (recommended)
|
||||
|
||||
Bind loopback, let Tailscale front it on your tailnet with a real cert:
|
||||
Bind loopback, let Tailscale front it on your tailnet with a real cert. **The
|
||||
installer sets this up for you**: choose **Tailscale** at the network-access
|
||||
prompt, or retrofit an existing install with:
|
||||
|
||||
```bash
|
||||
codeman web --https # binds 127.0.0.1:3000
|
||||
tailscale serve --bg https / http://127.0.0.1:3000
|
||||
bash ~/.codeman/app/install.sh tailscale
|
||||
```
|
||||
|
||||
Only devices on your tailnet can reach it; Tailscale handles identity. No app
|
||||
password and no `0.0.0.0` bind required. (This is the maintainer's production
|
||||
setup.)
|
||||
The guided flow installs Tailscale if needed, walks through login and the
|
||||
tailnet HTTPS-certificates toggle, and configures the equivalent of:
|
||||
|
||||
```bash
|
||||
codeman web # binds 127.0.0.1:3000 (plain HTTP is fine here)
|
||||
tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.net
|
||||
```
|
||||
|
||||
Only devices on your tailnet can reach it; Tailscale handles identity and
|
||||
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
|
||||
push work). No app password and no `0.0.0.0` bind required. (This is the
|
||||
maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for
|
||||
automation; the installer never runs `tailscale serve reset` and never touches
|
||||
serve mappings other than `443 -> Codeman's port`.
|
||||
|
||||
### B. Authenticated cloudflared tunnel + password
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# Tailscale Setup in the Installer (Plan)
|
||||
|
||||
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
|
||||
`install.sh`, instead of a one-line hint pointing at the docs. Today the safest
|
||||
recommended deployment (loopback bind + `tailscale serve`) is exactly what the
|
||||
maintainer's own prod runs, but a new user has to discover and wire it by hand.
|
||||
The installer should do it for them.
|
||||
|
||||
Status: IMPLEMENTED (2026-08-04). `install.sh` carries the 3-way network
|
||||
prompt, the guided Tailscale flow, and the `tailscale` subcommand; README,
|
||||
`docs/security-architecture.md` section A, and CLAUDE.md are updated. Verified
|
||||
live on the maintainer's prod host: `install.sh tailscale` took the idempotent
|
||||
kept-as-is path against the existing serve mapping (recognizing the legacy
|
||||
`https+insecure://` target), verified `https://<node>.ts.net/api/status`
|
||||
end-to-end, and left `tailscale serve status` byte-identical. Items 1-4, 7,
|
||||
and 10-12 of the manual matrix below still need a fresh machine to exercise.
|
||||
|
||||
## Why this is low-hanging fruit
|
||||
|
||||
Everything on the app side already works; this is almost purely installer UX:
|
||||
|
||||
- `.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES`
|
||||
(`src/web/network-auth-policy.ts`), so the always-on Host/Origin guard accepts
|
||||
`tailscale serve` traffic with zero configuration. No `CODEMAN_ALLOWED_HOSTS`
|
||||
needed.
|
||||
- The loopback bind is the server default and prints no warning; nothing to
|
||||
acknowledge, no `CODEMAN_PASSWORD` strictly required (the tailnet is the auth
|
||||
boundary; Tailscale authenticates the device before a packet ever reaches us).
|
||||
- `tailscale serve` terminates TLS with a real Let's Encrypt certificate for
|
||||
`<node>.<tailnet>.ts.net`. That gives users valid HTTPS with no self-signed
|
||||
cert warnings, and (because it is a proper secure context) working service
|
||||
worker, PWA install, and web push on phones. This is strictly better than
|
||||
`codeman web --https` for remote access.
|
||||
- SSE and WebSockets work through serve (proven by prod:
|
||||
`https://tnode.tailf80371.ts.net` fronting `127.0.0.1:3000` daily).
|
||||
- `docs/security-architecture.md` section "A. Tailscale serve (recommended)"
|
||||
already documents this as the preferred setup; the installer just does not
|
||||
implement it.
|
||||
|
||||
## UX design
|
||||
|
||||
### 1. The network-access prompt grows a Tailscale option
|
||||
|
||||
`choose_network_binding()` (install.sh:1051) currently offers two choices. New
|
||||
menu, with Tailscale first when it can be recommended:
|
||||
|
||||
```
|
||||
Network access
|
||||
|
||||
How should the Codeman dashboard be reachable?
|
||||
|
||||
1) Tailscale (recommended)
|
||||
Private VPN access from your phone/laptop, real HTTPS,
|
||||
no password needed. Works from anywhere, not just your Wi-Fi.
|
||||
2) Any device on your network (0.0.0.0)
|
||||
Open it straight from your phone or laptop on the same Wi-Fi.
|
||||
Less safe: set a password so only you control your agents.
|
||||
3) This machine only (127.0.0.1)
|
||||
Safest. Reach it remotely via Tailscale or a tunnel later.
|
||||
```
|
||||
|
||||
Choice mapping:
|
||||
|
||||
- Option 1 = bind `127.0.0.1` (unchanged server posture) + configure
|
||||
`tailscale serve`. Internally it is option 3 plus the serve setup, so all
|
||||
existing binding plumbing (`BIND_HOST`, service files, `read_existing_binding`)
|
||||
is untouched.
|
||||
- Options 2 and 3 behave exactly as today (renumbered).
|
||||
- Default choice: 1 when tailscale is installed and logged in, or when an
|
||||
existing serve mapping for our port is detected; otherwise keep today's
|
||||
defaults (1 -> 2, 2 -> 3 renumbering, preserving the "existing setup wins"
|
||||
rule). If tailscale is not installed, option 1 is still shown (the installer
|
||||
offers to install it), but the default stays on the current behavior so a
|
||||
bare Enter never pulls in new software.
|
||||
- Password: after choosing Tailscale, offer the password prompt as optional
|
||||
defense in depth with default skip ("the tailnet already authenticates your
|
||||
devices; add one anyway?"). No `BIND_ACK` needed since the bind is loopback.
|
||||
|
||||
### 2. The Tailscale flow (state machine)
|
||||
|
||||
New `setup_tailscale_access()` runs after the binding choice, before service
|
||||
setup, handling each state in order:
|
||||
|
||||
1. **Not installed.**
|
||||
- Linux: offer to run the official installer
|
||||
(`curl -fsSL https://tailscale.com/install.sh | sh`), which handles all
|
||||
distros and enables `tailscaled` at boot. This mirrors our own
|
||||
curl-pipe-bash story and avoids maintaining per-distro logic like the six
|
||||
`install_cloudflared_*` functions.
|
||||
- macOS: do not auto-install (the GUI app needs an interactive login).
|
||||
Offer `brew install --cask tailscale` when brew exists, else print the
|
||||
download link, then wait-and-retry or let the user skip.
|
||||
- Declined install => fall back to plain loopback (option 3 behavior) and
|
||||
print how to redo this later (`install.sh tailscale`, see below).
|
||||
2. **Installed but logged out** (`tailscale status --json` ->
|
||||
`.BackendState == "NeedsLogin"` or `"Stopped"`).
|
||||
- Run `tailscale up` (via `run_as_root` if needed). It prints an auth URL
|
||||
that works headless (user opens it on any device). Poll
|
||||
`.BackendState == "Running"` with a friendly spinner + timeout; on
|
||||
timeout, skip gracefully with re-run instructions.
|
||||
3. **Running: grant operator (Linux).** `sudo tailscale set --operator=$USER`
|
||||
so serve configuration (now and in the future) does not need root. Skip
|
||||
silently if we are already operator (probe: `tailscale serve status`
|
||||
exits 0) or sudo is declined; fall back to `run_as_root tailscale serve ...`.
|
||||
4. **HTTPS availability check.** `.CertDomains` empty or
|
||||
`.CurrentTailnet.MagicDNSEnabled == false` means the tailnet has not enabled
|
||||
MagicDNS / HTTPS certificates. Print the exact two toggles with the admin
|
||||
URL (https://login.tailscale.com/admin/dns: enable MagicDNS, then enable
|
||||
HTTPS Certificates), then offer "I enabled it, re-check" / "skip for now".
|
||||
No silent HTTP fallback: the pitch is real HTTPS, and a plain-HTTP serve
|
||||
would break the PWA/push story. Skipping falls back to loopback + re-run
|
||||
instructions.
|
||||
5. **Existing serve config check** (`tailscale serve status --json`).
|
||||
- Already proxying to our port (443 -> `127.0.0.1:$PORT`): keep it, report
|
||||
it, done. Re-running the installer must be idempotent.
|
||||
- Port 443 occupied by a DIFFERENT target: never clobber it. Ask whether to
|
||||
replace it or skip. (Prod itself has a second serve on :5000; blind
|
||||
`tailscale serve reset` would destroy user config. NEVER use `reset`.)
|
||||
6. **Configure.** `tailscale serve --bg $PORT` where `$PORT` is the install's
|
||||
Codeman port (default 3000; honor a preset `CODEMAN_PORT`). Serve targets
|
||||
plain HTTP on loopback; TLS terminates at tailscaled with the real cert.
|
||||
The `--bg` config persists in tailscaled state across reboots, so no extra
|
||||
service unit is needed.
|
||||
(Note: do NOT combine this with `codeman web --https`; that is what forces
|
||||
the awkward `https+insecure://` proxy target prod historically used. New
|
||||
installs should keep Codeman on plain HTTP behind serve.)
|
||||
7. **Verify end-to-end.** Derive the URL from `.Self.DNSName` (strip the
|
||||
trailing dot) and curl `https://<dnsname>/api/status` after the service is
|
||||
up, retrying for ~30s: the first request can be slow while the Let's
|
||||
Encrypt cert is issued. Print success with the URL, or the observed error
|
||||
with `tailscale serve status` output on failure. This follows the "always
|
||||
test before claiming it works" rule; a blind "done!" is not acceptable.
|
||||
|
||||
### 3. Closing summary and security notice
|
||||
|
||||
- The final summary gains a "Remote Access (Tailscale)" block, printed above
|
||||
the cloudflared block, showing the actual URL:
|
||||
|
||||
```
|
||||
Remote Access (Tailscale):
|
||||
https://tnode.tailf80371.ts.net (any device on your tailnet, HTTPS)
|
||||
tailscale serve status # inspect
|
||||
```
|
||||
|
||||
- `print_security_notice()` third branch (loopback) gets a variant: when a
|
||||
serve mapping for our port is detected, lead with "reachable on your tailnet
|
||||
at https://... (HTTPS, tailnet-only)" instead of the generic "do ONE of"
|
||||
list. Detection is dynamic (query `tailscale serve status --json` at print
|
||||
time), no marker persisted anywhere: tailscaled's own state is the single
|
||||
source of truth, so external changes never drift against a stale flag.
|
||||
|
||||
### 4. Standalone entry point: `install.sh tailscale`
|
||||
|
||||
Add a `tailscale` subcommand next to `update` / `uninstall` in the existing
|
||||
dispatch. It runs `setup_tailscale_access()` against the already-installed
|
||||
service (reads the port from the service file, requires an existing install).
|
||||
This serves:
|
||||
|
||||
- existing installs that predate the feature,
|
||||
- users who picked "this machine only" and changed their mind,
|
||||
- every "skip for now" branch above, all of which print this exact command.
|
||||
|
||||
One implementation, two entry points. No separate `scripts/tailscale-setup.sh`
|
||||
(unlike cloudflared, there is no long-running process for a `tunnel.sh`-style
|
||||
start/stop wrapper to manage; tailscaled owns the lifecycle).
|
||||
|
||||
### 5. Non-interactive / automation
|
||||
|
||||
- `CODEMAN_TAILSCALE=1` presets choice 1 (analogous to presetting
|
||||
`CODEMAN_HOST`). In non-interactive runs it only proceeds through states
|
||||
that need no human (already installed + logged in + HTTPS-enabled tailnet);
|
||||
anything requiring interaction (login URL, admin-console toggle, replacing a
|
||||
foreign serve mapping) warns and falls back to loopback. It never installs
|
||||
tailscale non-interactively.
|
||||
- `CODEMAN_NONINTERACTIVE=1` with an existing serve mapping: preserve it, same
|
||||
"never silently loosen/change" policy as `read_existing_binding`.
|
||||
- Document both in the header comment block of install.sh (the env-var
|
||||
reference at the top) and in the README.
|
||||
|
||||
## Edge cases and decisions
|
||||
|
||||
| Case | Decision |
|
||||
| ---- | -------- |
|
||||
| macOS GUI app without `tailscale` on PATH | `get_tailscale_path()` helper mirroring `get_cloudflared_path()`: check PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`. All calls go through it. |
|
||||
| Tailnet HTTPS certs disabled | Guided admin-console instructions + re-check loop; skip falls back to loopback. Never configure plain-HTTP serve. |
|
||||
| Port 443 serve exists for another app | Prompt replace/skip; never `tailscale serve reset` (destroys unrelated mappings). |
|
||||
| First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. |
|
||||
| `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. |
|
||||
| Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. |
|
||||
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
|
||||
| Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. |
|
||||
| User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). |
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- Server code: no changes required. Host guard already trusts `.ts.net`,
|
||||
loopback bind is already the default, SSE/WS already work through serve.
|
||||
- The two existing binding options and their semantics, `read_existing_binding`
|
||||
preservation, and the LAN+password flow.
|
||||
- `scripts/tunnel.sh` / cloudflared support (stays as the "no Tailscale
|
||||
account" alternative).
|
||||
- The security model: this feature only ever narrows exposure (loopback +
|
||||
authenticated overlay), never widens it.
|
||||
|
||||
## Files touched (implementation inventory)
|
||||
|
||||
| File | Change |
|
||||
| ---- | ------ |
|
||||
| `install.sh` | New: `check_tailscale`, `get_tailscale_path`, `tailscale_status_field` (jq-free JSON field extraction; the installer cannot assume jq: use `sed`/`grep` like existing helpers or `tailscale status --json` piped to `node -e` since node is guaranteed post-install), `offer_install_tailscale`, `ensure_tailscale_login`, `ensure_tailscale_operator`, `ensure_tailnet_https`, `setup_tailscale_serve`, `verify_tailscale_access`, `setup_tailscale_access` (orchestrator). Modified: `choose_network_binding` (3-way menu), summary block, `print_security_notice`, subcommand dispatch (`tailscale`), `uninstall` (targeted serve removal), header env-var docs (`CODEMAN_TAILSCALE`). |
|
||||
| `README.md` | Remote-access section: promote the Tailscale path with the one-liner and `install.sh tailscale`; keep the tailscale-IP HTTP note for non-serve users but recommend serve + HTTPS. |
|
||||
| `docs/security-architecture.md` | Section A gains "the installer can set this up for you" + `install.sh tailscale` pointer. |
|
||||
| `CLAUDE.md` | One line in Scripts & Tunnel: installer offers Tailscale setup (`install.sh tailscale` to redo). |
|
||||
| `test/` | No unit tests possible for interactive bash + a live tailnet; guard with `shellcheck install.sh` (already the norm) and the manual matrix below. |
|
||||
|
||||
## Manual test matrix (before release)
|
||||
|
||||
1. Linux + tailscale absent: install offered, declined => loopback fallback + hint.
|
||||
2. Linux + tailscale absent: install accepted => full flow => URL verified.
|
||||
3. Logged out => auth URL flow => Running => serve configured.
|
||||
4. Tailnet with HTTPS certs disabled => guided instructions => re-check => success; and the skip branch.
|
||||
5. Re-run installer with serve already configured => idempotent, preserved, reported.
|
||||
6. Second serve mapping on another port present => untouched (prod-like state).
|
||||
7. Port 443 already proxying another target => replace/skip prompt honored.
|
||||
8. `install.sh tailscale` on an existing loopback install (the retrofit path).
|
||||
9. `CODEMAN_NONINTERACTIVE=1` re-run => preserves everything, no prompts.
|
||||
10. macOS (Mac mini `arbbot` box): GUI-app CLI path detection + full flow.
|
||||
11. Uninstall removes only our 443 mapping, leaves others.
|
||||
12. Phone check: PWA install + push from the `https://*.ts.net` origin.
|
||||
|
||||
## Release
|
||||
|
||||
Changeset: `minor` (new documented installer capability + new `CODEMAN_TAILSCALE`
|
||||
env var). The feature is installer-only, so it ships with zero risk to running
|
||||
servers; `install.sh update` does not invoke the new flow (updates never rewrite
|
||||
access config), only fresh installs and the explicit `install.sh tailscale`
|
||||
subcommand do.
|
||||
@@ -0,0 +1,303 @@
|
||||
# Terminal smart copy (Ctrl+C) plan
|
||||
|
||||
Issue: [#211](https://github.com/Ark0N/Codeman/issues/211) "Terminal: Ctrl+C should copy when text is selected (interrupt otherwise)".
|
||||
Origin: r/selfhosted feedback, "Biggest stumbling block is apparent lack of copy-paste in the terminal."
|
||||
|
||||
Status: **implemented and shipped** on 2026-08-05 (this document is kept as the rationale record). It was first served as an isolated beta over Tailscale for manual sign-off, then landed. Section 2 is the research that shaped the design, sections 4 to 6 describe what was built.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the issue asks for
|
||||
|
||||
- Text selected in the terminal + `Ctrl+C` -> copy the selection, toast, clear the selection, do NOT send the byte to the PTY.
|
||||
- No selection + `Ctrl+C` -> unchanged, the interrupt (`0x03`) reaches the PTY.
|
||||
- `Ctrl+Shift+C` as an explicit copy chord.
|
||||
- The selection check must run before the shortcut registry dispatch so a rebind cannot cost the user their interrupt key.
|
||||
- Paste is out of scope (it already works via `Ctrl+V`, which terminal-ui.js routes to the image/text paste trap).
|
||||
|
||||
## 2. Verified current behavior
|
||||
|
||||
### 2.1 xterm cancels the Ctrl+C keydown, so no copy can happen
|
||||
|
||||
`src/web/public/vendor/xterm.min.js` (xterm 6.x), `_keyDown`:
|
||||
|
||||
```js
|
||||
_keyDown(x){ if(this._keyDownHandled=!1, this._keyDownSeen=!0,
|
||||
this._customKeyEventHandler && this._customKeyEventHandler(x)===!1) return !1;
|
||||
... evaluateKeyboardEvent(...) ... this.cancel(x) ... }
|
||||
```
|
||||
|
||||
Two consequences that shape the design:
|
||||
|
||||
1. The custom handler runs **first**, before xterm evaluates the key. Returning `false` exits before `cancel(x)`, so returning `false` does **not** call `preventDefault()` for us.
|
||||
2. When the handler returns `true`, xterm turns Ctrl+C into `0x03` and cancels the event, which is why the browser's own copy command never runs.
|
||||
|
||||
Probe (headless chromium against an isolated server on port 3174, selection active, real focus on `.xterm-helper-textarea`, synthetic Ctrl+C keydown):
|
||||
|
||||
```json
|
||||
{ "hasSelection": true, "defaultPrevented": true, "dataSeen": ["\"\\u0003\""],
|
||||
"clipboardAfter": "SENTINEL-BEFORE", "stillHasSelection": false }
|
||||
```
|
||||
|
||||
So today: interrupt byte sent, clipboard untouched, and xterm drops the selection anyway. The last point matters, "copy then clear the selection" is not a behavior change in how the selection feels, it is what already happens on any keypress.
|
||||
|
||||
### 2.2 Why right-click Copy works today
|
||||
|
||||
xterm registers a `copy` listener on its root element that substitutes the selection text:
|
||||
|
||||
```js
|
||||
this._register(addDisposableListener(this.element,"copy",(k=>{ this.hasSelection() && copyHandler(k,this._selectionService) })))
|
||||
```
|
||||
|
||||
Second probe (port 3175, real `page.keyboard.press('Control+c')`, custom handler patched to return `false` for Ctrl+C without `preventDefault`):
|
||||
|
||||
```json
|
||||
{ "dataSeen": [], "copyEvents": ["xterm-element"],
|
||||
"clipboardAfter": "native-copy-probe-line\n...", "stillHasSelection": true }
|
||||
```
|
||||
|
||||
So a "return false and let the browser copy" implementation would also work in Chromium. It is rejected below (section 3.3) because it gives no toast, does not clear the selection, and leans on per-browser behavior of the copy command when the focused element is xterm's empty helper textarea.
|
||||
|
||||
### 2.3 The document-level capture handler will not interfere
|
||||
|
||||
`setupEventListeners()` in `src/web/public/app.js:989` runs on document capture, before xterm's textarea listener. Its registry loop skips any entry whose action is not in the local `SHORTCUT_ACTIONS` map:
|
||||
|
||||
```js
|
||||
if (shortcut.disabled || !shortcut.action) continue;
|
||||
const action = SHORTCUT_ACTIONS[shortcut.action];
|
||||
if (!action) continue;
|
||||
```
|
||||
|
||||
This is exactly how `command-palette` already behaves: it is a full registry entry (rebindable and disableable in App Settings) whose dispatch happens in a dedicated, focus-aware gate rather than the generic loop. The new copy entry follows that pattern, so the capture handler falls through untouched and the terminal handler owns the decision.
|
||||
|
||||
### 2.4 Registry matching rules that constrain the bindings
|
||||
|
||||
`matchesShortcutEvent()` (`app.js:4890`):
|
||||
|
||||
- Ctrl and Cmd are interchangeable as the primary modifier, so a `['ctrl']` binding also matches Cmd+C on macOS. That is fine here: with a selection it copies (same result the native macOS path gives today), without one it falls through.
|
||||
- Every other modifier must be declared exactly: `if (mods.includes('shift') !== !!e.shiftKey) return false`. So `Ctrl+Shift+C` needs its own binding, a plain `ctrl+c` binding will never swallow it.
|
||||
- `binding.code` wins when present, otherwise `binding.key` is compared case-insensitively.
|
||||
|
||||
### 2.5 Where selection is actually possible
|
||||
|
||||
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
||||
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
||||
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
||||
|
||||
### 2.6 Helpers that already exist and should be reused
|
||||
|
||||
| Need | Existing code |
|
||||
| --- | --- |
|
||||
| Clipboard write with an HTTP-safe fallback | `_copyText(text)` in `app.js:1887` (Clipboard API, then hidden textarea + `execCommand`) |
|
||||
| Toast | `showToast(message, type)` in `panels-ui.js:4385` |
|
||||
| Translated string | `'Copied to clipboard'` already in `i18n.js:453` |
|
||||
| Focus-aware chord gate to copy the shape of | `shouldOpenCommandPaletteFromShortcut(e)` in `panels-ui.js:285` |
|
||||
| Buffer-wide copy (currently unreferenced) | `copyTerminal()` in `terminal-ui.js:2615` |
|
||||
|
||||
`_copyText` matters more than it looks: `install.sh`'s LAN option serves plain HTTP, where `navigator.clipboard` is undefined. The issue's suggested `navigator.clipboard.writeText` alone would silently do nothing for those users, the `execCommand` fallback covers them.
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Behavior
|
||||
|
||||
| Chord | Selection present | No selection |
|
||||
| --- | --- | --- |
|
||||
| `Ctrl+C` (and Cmd+C, per registry equivalence) | copy, toast, clear selection, swallow the key | fall through, xterm sends `0x03` (interrupt) |
|
||||
| `Ctrl+Shift+C` | copy, toast, clear selection, swallow the key | swallow, no-op (see 3.2) |
|
||||
| Shortcut disabled in App Settings | never copies, `Ctrl+C` is always the interrupt | unchanged |
|
||||
| Rebound to another chord | that chord copies when a selection exists | plain `Ctrl+C` is always the interrupt |
|
||||
|
||||
### 3.2 Why `Ctrl+Shift+C` with no selection is swallowed rather than forwarded
|
||||
|
||||
Today `Ctrl+Shift+C` produces `0x03` as well (the shift is irrelevant to the control byte), so forwarding would be "no regression". But once the chord is advertised as *the explicit copy key*, letting it interrupt a running agent when the selection happens to be empty is a footgun with no upside. Swallowing costs nothing: a user who wants to interrupt has `Ctrl+C` right there.
|
||||
|
||||
The rule in code is "no selection and the matched chord had Shift -> swallow", not a hardcoded key check, so it stays correct under rebinds.
|
||||
|
||||
### 3.3 Why an explicit clipboard write rather than falling through to the native copy
|
||||
|
||||
Probe 2 showed the native path works in Chromium, but the explicit write is chosen because it:
|
||||
|
||||
- gives the "Copied to clipboard" toast, which is the discoverability half of the issue,
|
||||
- clears the selection so a second `Ctrl+C` interrupts (the smart-copy contract),
|
||||
- works on plain-HTTP LAN installs through `_copyText`'s `execCommand` fallback,
|
||||
- does not depend on how each browser treats a copy command issued while an empty textarea has focus.
|
||||
|
||||
### 3.4 Why no new app setting
|
||||
|
||||
Per-shortcut enable/disable and rebinding already exist in App Settings -> Shortcuts and are driven by the registry. A user who wants "Ctrl+C is always interrupt" unchecks one box. Adding a `terminalSmartCopy` setting would duplicate that and would drag in the per-device vs synced decision (`displayKeys` + `.strict()` `SettingsUpdateSchema`) for no gain.
|
||||
|
||||
## 4. Code changes, file by file
|
||||
|
||||
### 4.1 `src/web/public/app.js`, registry entry
|
||||
|
||||
Add to `DEFAULT_SHORTCUTS` (after the `clear-terminal` entry, ~line 351) so the Terminal group stays together:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'copy-selection',
|
||||
group: 'Terminal',
|
||||
label: 'Copy Selection',
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: 'c' },
|
||||
{ modifiers: ['ctrl', 'shift'], key: 'C' },
|
||||
],
|
||||
// Dispatched by shouldCopyTerminalSelectionFromShortcut() in terminal-ui.js,
|
||||
// deliberately NOT in SHORTCUT_ACTIONS: the generic capture loop always
|
||||
// preventDefaults, which would cost the user the interrupt key.
|
||||
action: 'copyTerminalSelection',
|
||||
},
|
||||
```
|
||||
|
||||
Match on `key`, not `code`. xterm decides what byte to emit from the produced character, so intercepting the physical `KeyC` on a layout where it does not produce "c" would diverge from what xterm would have sent.
|
||||
|
||||
The `action` string is required for App Settings to render the row as configurable (`configurable = !!shortcut.action && Array.isArray(shortcut.bindings)`, `settings-ui.js:2624`). Do **not** add `copyTerminalSelection` to `SHORTCUT_ACTIONS`.
|
||||
|
||||
### 4.2 `src/web/public/terminal-ui.js`, the gate
|
||||
|
||||
New prototype method, modeled on `shouldOpenCommandPaletteFromShortcut`:
|
||||
|
||||
```js
|
||||
shouldCopyTerminalSelectionFromShortcut(ev) {
|
||||
if (!ev || ev.type !== 'keydown') return false; // the handler also runs for keypress/keyup
|
||||
if (!ev.ctrlKey && !ev.metaKey && !ev.altKey) return false; // hot path: plain typing exits here
|
||||
const registryAvailable =
|
||||
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
|
||||
const entry = registryAvailable
|
||||
? this.getShortcutRegistry().find((s) => s.id === 'copy-selection')
|
||||
: null;
|
||||
if (entry) return !entry.disabled && this.matchesShortcutEvent(ev, entry);
|
||||
return (ev.key || '').toLowerCase() === 'c' && !ev.altKey; // fallback for isolated harnesses
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `src/web/public/terminal-ui.js`, the branch
|
||||
|
||||
Inside `attachCustomKeyEventHandler` (`terminal-ui.js:133`), after the command-palette gate and before the `Ctrl+V` branch:
|
||||
|
||||
```js
|
||||
// Smart copy (#211): with a selection, Ctrl+C copies instead of sending ^C.
|
||||
// With no selection it MUST fall through (return true, no preventDefault) or
|
||||
// the interrupt key is lost. Ctrl+Shift+C is the explicit chord and never
|
||||
// falls through: an "explicit copy" that interrupts the agent is a footgun.
|
||||
if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||
if (selection) {
|
||||
ev.preventDefault();
|
||||
void this.copyTerminalSelection(selection);
|
||||
return false;
|
||||
}
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
`preventDefault()` is explicit because returning `false` alone does not cancel the event (section 2.1), and without it the browser would run its own copy on top of ours.
|
||||
|
||||
### 4.4 `src/web/public/terminal-ui.js`, the copy action
|
||||
|
||||
```js
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
if (!selection) return false;
|
||||
const ok = await this._copyText(selection);
|
||||
if (ok) {
|
||||
this.terminal.clearSelection?.();
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
} else {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
}
|
||||
// _copyText's execCommand fallback focuses a temp textarea; restore the
|
||||
// terminal (this.terminal.focus is the CJK-aware router, not xterm's raw focus).
|
||||
this.terminal.focus();
|
||||
return ok;
|
||||
}
|
||||
```
|
||||
|
||||
The selection text is captured **before** the first `await`, and `navigator.clipboard.writeText` is reached in the same task as the keydown, so user activation still holds.
|
||||
|
||||
### 4.5 `src/web/public/i18n.js`
|
||||
|
||||
`'Copied to clipboard'` exists. Add `'Failed to copy': '复制失败'` (the error path is new to this surface).
|
||||
|
||||
### 4.6 Documentation
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `README.md` shortcut table (~line 648) | `\| `Ctrl/Cmd+C` \| Copy selection (interrupts when nothing is selected) \|` and a `Ctrl+Shift+C` row |
|
||||
| `src/web/public/index.html` help modal, Terminal section (~line 641) | `<div><kbd>Ctrl</kbd>+<kbd>C</kbd></div><div>Copy Selection / Interrupt</div>` plus the Ctrl+Shift+C row. Keep the existing negative assertion in `help-modal-shortcuts.test.ts` in mind (it forbids `Ctrl+K`, `C` is fine) |
|
||||
| `CLAUDE.md` "Keyboard shortcuts" line | add `Ctrl+C` (copy selection, else interrupt) and `Ctrl+Shift+C` |
|
||||
| `docs/architecture-invariants.md` -> "Command palette and shortcut registry" | append the invariant: the no-selection path must return `true` without `preventDefault`, the branch is keydown-only, and `copyTerminalSelection` must stay out of `SHORTCUT_ACTIONS` |
|
||||
|
||||
The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-driven and pick the entry up with no edit.
|
||||
|
||||
## 5. Edge cases and risks
|
||||
|
||||
| Case | Handling |
|
||||
| --- | --- |
|
||||
| Handler also fires for `keypress`/`keyup` | gated on `ev.type === 'keydown'`. xterm's `_keyPress` bails on ctrl combos anyway, so no stray byte |
|
||||
| CJK IME composing | the existing `isComposing || keyCode === 229` guard is the first line of the handler and stays first |
|
||||
| Local echo overlay has unsent `pendingText` | the copy branch returns before `onData`, so `pendingText`, flushed offsets and the durable input queue are untouched. The no-selection path is byte-identical to today, including the "control char flushes buffered text then sends `0x03`" logic at `terminal-ui.js:895` |
|
||||
| Plain HTTP (LAN install) | `_copyText` falls back to `execCommand`, then focus is restored |
|
||||
| Clipboard write rejected (permissions policy, no gesture) | error toast, right-click Copy still available |
|
||||
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
||||
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
||||
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
||||
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
||||
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
||||
|
||||
## 6. Test plan
|
||||
|
||||
New file `test/terminal-copy-selection.test.ts` (node env, `vm` harness in the style of `test/command-palette-ui.test.ts`), covering `shouldCopyTerminalSelectionFromShortcut` in isolation:
|
||||
|
||||
1. Ctrl+C keydown -> true, keyup/keypress of the same chord -> false.
|
||||
2. Ctrl+Shift+C -> true, plain `c` -> false, Ctrl+K -> false.
|
||||
3. Registry entry `disabled: true` -> false for every chord.
|
||||
4. Rebound entry (for example Alt+Y) -> true for the rebind, false for Ctrl+C.
|
||||
5. Missing registry (harness without `getShortcutRegistry`) -> falls back to the `c` check.
|
||||
|
||||
Static assertions appended to `test/keyboard-shortcuts.test.ts` (this suite already pins the xterm-handler chokepoint):
|
||||
|
||||
6. `DEFAULT_SHORTCUTS` contains `id: 'copy-selection'` and `SHORTCUT_ACTIONS` does **not** contain `copyTerminalSelection` (the interrupt-safety invariant).
|
||||
7. `terminal-ui.js` contains the `shouldCopyTerminalSelectionFromShortcut` branch and a `return true` no-selection fall-through.
|
||||
8. README + help modal rows exist (mirrors the existing palette/Alt-nav doc assertions).
|
||||
|
||||
`test/help-modal-shortcuts.test.ts`: add `expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection')`.
|
||||
|
||||
New browser test `test/terminal-copy-shortcut.test.ts` (Playwright, port **3174**, free per a scan of `test/`), following `test/webgl-fallback.test.ts`: boot `WebServer`, grant `clipboard-read`/`clipboard-write`, `terminal.write()` a known line, `selectLines()`, real `page.keyboard.press('Control+c')`, then assert clipboard content, empty `onData` capture, cleared selection and the toast. Second case: no selection, assert `onData` saw `\u0003` and the clipboard is unchanged.
|
||||
Per repo convention, browser suites are excluded from CI, so add the filename to the exclude list in `config/vitest.ci.config.ts` and run it locally.
|
||||
|
||||
Regression runs: `npm test -- test/keyboard-shortcuts.test.ts`, `test/help-modal-shortcuts.test.ts`, `test/command-palette-ui.test.ts`, `test/input-send-order.test.ts`, then `npm run test:ci`.
|
||||
|
||||
## 7. Manual verification before COM (CLAUDE.md rule)
|
||||
|
||||
Against a throwaway session on the live instance (`curl -sk https://localhost:3000/...`, never w1/w2/w3):
|
||||
|
||||
1. Select output with the mouse, press Ctrl+C, confirm the toast, paste elsewhere, confirm the agent did not stop.
|
||||
2. Press Ctrl+C again with nothing selected, confirm the agent interrupts.
|
||||
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
||||
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
||||
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
||||
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
|
||||
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
||||
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
||||
|
||||
## 8. Out of scope, follow-ups worth filing separately
|
||||
|
||||
- **Teammate/subagent terminals** (`panels-ui.js:2268`) have the same blocked-copy problem. One `attachCustomKeyEventHandler` reusing `copyTerminalSelection` would fix them, but it touches a different surface and deserves its own change.
|
||||
- **A mobile copy affordance.** Selection is disabled on touch, so phones still cannot copy terminal text. The unreferenced `copyTerminal()` (whole buffer) plus a keyboard-accessory "Copy" button would be the cheapest answer.
|
||||
- **Right-click context menu** with Copy/Paste, better discoverability than any chord, but a bigger UI surface.
|
||||
- **`copyTerminal()` cleanup**: it uses raw `navigator.clipboard` rather than `_copyText`, so it would fail on plain HTTP if ever wired up.
|
||||
|
||||
## 9. PR mechanics
|
||||
|
||||
- Branch off `master` (verify with `git branch --show-current`, the tree is shared), stage explicit paths only.
|
||||
- Files touched: `src/web/public/app.js`, `src/web/public/terminal-ui.js`, `src/web/public/i18n.js`, `src/web/public/index.html`, `README.md`, `CLAUDE.md`, `docs/architecture-invariants.md`, `docs/terminal-copy-shortcut-plan.md`, three test files, `config/vitest.ci.config.ts`.
|
||||
- `index.html`, `app.js` and `terminal-ui.js` are `.prettierignore`d hand-formatted assets, match the surrounding style by hand. `npm run check:public-assets` and `npm run check:frontend-syntax` are the guards.
|
||||
- No changeset in this PR: a merged, unconsumed changeset turns the Release workflow red until the next COM, and the COM flow writes release notes covering everything since the last tag (current version is 1.10.0).
|
||||
- Close #211 from the PR body.
|
||||
|
||||
Rough size: about 60 lines of product code, most of the work is the tests and the four documentation surfaces.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Web tabs: two fixes (planned + implemented 2026-07-28)
|
||||
|
||||
Both found against the saved dashboard
|
||||
`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard).
|
||||
`https://<your-host>.<your-tailnet>.ts.net:4000` (Bio-Hacking-Dashboard).
|
||||
Kept because the root-cause analysis of the second one is not obvious from the
|
||||
resulting diff.
|
||||
|
||||
@@ -49,7 +49,7 @@ already revoked the capability and broadcast `WebviewChanged`.
|
||||
```
|
||||
CAP=<from POST /api/webviews/<id>/open>
|
||||
# A) upstream direct -> 200 image/jpeg 118150
|
||||
curl -sk "https://macminis-mac-mini.tailf80371.ts.net:4000/api/hero?slug=120-minutes-in-nature"
|
||||
curl -sk "https://<your-host>.<your-tailnet>.ts.net:4000/api/hero?slug=120-minutes-in-nature"
|
||||
# B) through the proxy prefix -> 200 image/jpeg 118150
|
||||
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
|
||||
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
|
||||
|
||||
+560
-19
@@ -21,6 +21,16 @@
|
||||
# non-interactive default is 127.0.0.1)
|
||||
# CODEMAN_PASSWORD - Preset the dashboard password (skips the
|
||||
# password prompt when binding to the network)
|
||||
# CODEMAN_TAILSCALE=1 - Preset the Tailscale choice: bind loopback and
|
||||
# front it with `tailscale serve` HTTPS (skips
|
||||
# the network prompt; never installs Tailscale
|
||||
# in non-interactive runs)
|
||||
#
|
||||
# Subcommands:
|
||||
# install.sh update - Update an existing install
|
||||
# install.sh uninstall - Remove services, symlinks and (optionally) data
|
||||
# install.sh tailscale - Set up (or repair) Tailscale serve HTTPS access
|
||||
# for an existing install
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -51,6 +61,14 @@ EXISTING_HOST=""
|
||||
EXISTING_PASSWORD=""
|
||||
EXISTING_ACK="0"
|
||||
|
||||
# Tailscale serve URL configured or detected during this run
|
||||
# (setup_tailscale_access / detect_tailscale_serve_url). Empty when the
|
||||
# Tailscale path was not taken or not completed.
|
||||
TAILSCALE_SERVE_URL=""
|
||||
# Set to 1 when serve commands must go through sudo because granting the user
|
||||
# tailscale "operator" rights failed (ensure_tailscale_operator).
|
||||
TS_NEED_ROOT="0"
|
||||
|
||||
# puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its
|
||||
# ~150MB chrome-headless-shell download is never needed to build or run Codeman.
|
||||
# Skipping it avoids a slow download and a fatal install failure when a prior
|
||||
@@ -177,13 +195,28 @@ print_security_notice() {
|
||||
echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel."
|
||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}${BOLD}Security:${NC}"
|
||||
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
|
||||
echo -e " To reach it from another device, do ONE of:"
|
||||
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
|
||||
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
|
||||
echo -e " A non-loopback bind without a password still starts, but warns loudly."
|
||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||
# Loopback bind: when a tailscale serve mapping fronts it, lead with
|
||||
# the actual URL instead of the generic "do ONE of" list. Detection is
|
||||
# dynamic (tailscaled state is the single source of truth).
|
||||
local notice_ts_url="$TAILSCALE_SERVE_URL"
|
||||
if [[ -z "$notice_ts_url" ]]; then
|
||||
notice_ts_url=$(detect_tailscale_serve_url 2>/dev/null) || notice_ts_url=""
|
||||
fi
|
||||
if [[ -n "$notice_ts_url" ]]; then
|
||||
echo -e " ${YELLOW}${BOLD}Security:${NC}"
|
||||
echo -e " Codeman binds ${BOLD}127.0.0.1${NC}, fronted by Tailscale serve:"
|
||||
echo -e " reachable at ${BOLD}$notice_ts_url${NC} (HTTPS, your tailnet only)."
|
||||
echo -e " Tailscale authenticates every device before traffic reaches Codeman."
|
||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}${BOLD}Security:${NC}"
|
||||
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
|
||||
echo -e " To reach it from another device, do ONE of:"
|
||||
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
|
||||
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
|
||||
echo -e " A non-loopback bind without a password still starts, but warns loudly."
|
||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
@@ -1050,6 +1083,7 @@ read_existing_binding() {
|
||||
# preset. The server binary itself still defaults to 127.0.0.1 either way.
|
||||
choose_network_binding() {
|
||||
# Preset via environment: honor it and skip the prompt entirely.
|
||||
# CODEMAN_TAILSCALE=1 composes with a loopback (or absent) CODEMAN_HOST.
|
||||
if [[ -n "${CODEMAN_HOST:-}" ]]; then
|
||||
BIND_HOST="$CODEMAN_HOST"
|
||||
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
|
||||
@@ -1057,6 +1091,20 @@ choose_network_binding() {
|
||||
BIND_ACK="1"
|
||||
fi
|
||||
info "Network binding preset via CODEMAN_HOST: $BIND_HOST"
|
||||
if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then
|
||||
if [[ "$BIND_HOST" == "127.0.0.1" ]]; then
|
||||
setup_tailscale_access || true
|
||||
else
|
||||
warn "CODEMAN_TAILSCALE=1 ignored: CODEMAN_HOST=$BIND_HOST is not loopback."
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then
|
||||
BIND_HOST="127.0.0.1"
|
||||
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
|
||||
info "Tailscale access preset via CODEMAN_TAILSCALE=1"
|
||||
setup_tailscale_access || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -1077,21 +1125,48 @@ choose_network_binding() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Default follows the existing setup when there is one, else network.
|
||||
local default_choice="1"
|
||||
# Tailscale state, for the menu hint and the default choice. Detection
|
||||
# only; never installs, logs in, or prompts for sudo here.
|
||||
local ts_hint="will be installed for you" ts_ready="0" ts_detected_url=""
|
||||
if check_tailscale; then
|
||||
ts_hint="installed, needs login"
|
||||
if command -v node &>/dev/null && [[ "$(ts_status_field 's.BackendState')" == "Running" ]]; then
|
||||
ts_ready="1"
|
||||
ts_hint="already connected"
|
||||
ts_detected_url=$(detect_tailscale_serve_url) || ts_detected_url=""
|
||||
if [[ -n "$ts_detected_url" ]]; then
|
||||
ts_hint="already serving Codeman"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Defaults: an existing setup wins (existing loopback installs default to
|
||||
# Tailscale only when its serve mapping is already present); fresh installs
|
||||
# default to Tailscale when it is already connected, else network access.
|
||||
# A bare Enter never pulls in new software.
|
||||
local default_choice="2"
|
||||
if [[ "$EXISTING_FOUND" == "1" && "$EXISTING_HOST" == "127.0.0.1" ]]; then
|
||||
default_choice="2"
|
||||
if [[ -n "$ts_detected_url" ]]; then
|
||||
default_choice="1"
|
||||
else
|
||||
default_choice="3"
|
||||
fi
|
||||
elif [[ "$EXISTING_FOUND" != "1" && "$ts_ready" == "1" ]]; then
|
||||
default_choice="1"
|
||||
fi
|
||||
|
||||
echo -e " ${BOLD}Network access${NC}"
|
||||
echo ""
|
||||
echo -e " How should the Codeman dashboard be reachable?"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC}"
|
||||
echo -e " Open it straight from your phone or laptop."
|
||||
echo -e " ${CYAN}1)${NC} ${BOLD}Tailscale${NC} ${DIM}($ts_hint)${NC}"
|
||||
echo -e " Private VPN access from your phone or laptop, anywhere."
|
||||
echo -e " Real HTTPS, no password needed: your tailnet is the login."
|
||||
echo -e " ${CYAN}2)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC}"
|
||||
echo -e " Open it straight from your phone or laptop on the same Wi-Fi."
|
||||
echo -e " ${YELLOW}Less safe: set a password so only you control your agents.${NC}"
|
||||
echo -e " ${CYAN}2)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}"
|
||||
echo -e " Safest. Reach it remotely via Tailscale or a tunnel."
|
||||
echo -e " ${CYAN}3)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}"
|
||||
echo -e " Safest. Reach it remotely via Tailscale or a tunnel later."
|
||||
echo ""
|
||||
if [[ "$EXISTING_FOUND" == "1" ]]; then
|
||||
echo -e " ${DIM}Current setup: $EXISTING_HOST$([[ -n "$EXISTING_PASSWORD" ]] && echo ", password set"). Enter keeps it.${NC}"
|
||||
@@ -1100,21 +1175,55 @@ choose_network_binding() {
|
||||
|
||||
local bind_choice=""
|
||||
while true; do
|
||||
echo -en "${CYAN}Choose [1/2] (default $default_choice):${NC} " >&2
|
||||
echo -en "${CYAN}Choose [1/2/3] (default $default_choice):${NC} " >&2
|
||||
read_reply bind_choice || bind_choice="$default_choice"
|
||||
bind_choice="${bind_choice:-$default_choice}"
|
||||
case "$bind_choice" in
|
||||
1|2) break ;;
|
||||
*) echo "Please enter 1 or 2." >&2 ;;
|
||||
1|2|3) break ;;
|
||||
*) echo "Please enter 1, 2, or 3." >&2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$bind_choice" == "2" ]]; then
|
||||
if [[ "$bind_choice" == "3" ]]; then
|
||||
BIND_HOST="127.0.0.1"
|
||||
success "Binding 127.0.0.1 (this machine only)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$bind_choice" == "1" ]]; then
|
||||
BIND_HOST="127.0.0.1"
|
||||
setup_tailscale_access || true
|
||||
|
||||
# Password is optional here: the tailnet already authenticates devices.
|
||||
# An existing password is always kept (never silently loosen).
|
||||
if [[ -n "$EXISTING_PASSWORD" ]]; then
|
||||
BIND_PASSWORD="$EXISTING_PASSWORD"
|
||||
info "Keeping the existing dashboard password"
|
||||
elif [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
|
||||
BIND_PASSWORD="$CODEMAN_PASSWORD"
|
||||
info "Using CODEMAN_PASSWORD from the environment"
|
||||
elif prompt_yes_no "Add a dashboard password too? (optional; your tailnet already authenticates your devices)" "n"; then
|
||||
local ts_pw="" ts_pw2=""
|
||||
while true; do
|
||||
echo -en "${CYAN}Dashboard password:${NC} " >&2
|
||||
read_secret ts_pw || ts_pw=""
|
||||
if [[ -z "$ts_pw" ]]; then
|
||||
info "No password set"
|
||||
break
|
||||
fi
|
||||
echo -en "${CYAN}Confirm password:${NC} " >&2
|
||||
read_secret ts_pw2 || ts_pw2=""
|
||||
if [[ "$ts_pw" == "$ts_pw2" ]]; then
|
||||
BIND_PASSWORD="$ts_pw"
|
||||
success "Password set (login user: admin)"
|
||||
break
|
||||
fi
|
||||
echo "Passwords do not match, try again." >&2
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Keep a custom non-loopback host from a previous install (e.g. a specific
|
||||
# interface IP); otherwise bind all interfaces.
|
||||
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
|
||||
@@ -1162,6 +1271,403 @@ choose_network_binding() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Tailscale Access (loopback bind fronted by `tailscale serve` HTTPS)
|
||||
# ============================================================================
|
||||
# The recommended remote-access setup: Codeman stays on 127.0.0.1 and
|
||||
# tailscaled fronts it with a real Let's Encrypt certificate for
|
||||
# https://<node>.<tailnet>.ts.net, reachable from the user's tailnet only.
|
||||
# The app side needs zero configuration (.ts.net is in the server's trusted
|
||||
# host suffixes). All state lives in tailscaled: no marker files, `tailscale
|
||||
# serve status` is the single source of truth, and `--bg` config persists
|
||||
# across reboots on its own.
|
||||
#
|
||||
# Safety rule for every function here: NEVER `tailscale serve reset` and never
|
||||
# touch mappings other than 443 -> Codeman's port. Users may have unrelated
|
||||
# serve config (other ports, other apps) that a reset would destroy.
|
||||
|
||||
get_tailscale_path() {
|
||||
if command -v tailscale &>/dev/null; then
|
||||
command -v tailscale
|
||||
return 0
|
||||
fi
|
||||
# macOS GUI app (App Store or brew cask) ships the CLI inside the bundle
|
||||
# and does not put it on PATH.
|
||||
if [[ -x "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]]; then
|
||||
echo "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
check_tailscale() {
|
||||
get_tailscale_path >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ts_cmd() {
|
||||
local ts_bin
|
||||
ts_bin=$(get_tailscale_path) || return 127
|
||||
"$ts_bin" "$@"
|
||||
}
|
||||
|
||||
# Serve mutations need root or "operator" rights on Linux; TS_NEED_ROOT is set
|
||||
# by ensure_tailscale_operator when the operator grant failed. Detection paths
|
||||
# run with TS_NEED_ROOT=0 and must never trigger a sudo prompt.
|
||||
ts_cmd_serve() {
|
||||
local ts_bin
|
||||
ts_bin=$(get_tailscale_path) || return 127
|
||||
if [[ "$TS_NEED_ROOT" == "1" ]]; then
|
||||
run_as_root "$ts_bin" "$@"
|
||||
else
|
||||
"$ts_bin" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ts_status_field <js-expr>: evaluate an expression against the parsed
|
||||
# `tailscale status --json` object bound to `s`, printing the result (empty on
|
||||
# any error). node is guaranteed at every call site (the installer installs it
|
||||
# before the binding prompt; the subcommand requires a completed install).
|
||||
ts_status_field() {
|
||||
ts_cmd status --json 2>/dev/null | node -e '
|
||||
let d = "";
|
||||
process.stdin.on("data", (c) => (d += c));
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const s = JSON.parse(d);
|
||||
const v = eval(process.argv[1]);
|
||||
if (v !== undefined && v !== null && v !== false) process.stdout.write(String(v));
|
||||
} catch {}
|
||||
});
|
||||
' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Print the local port that the :443 web handler proxies to, empty when 443 is
|
||||
# unconfigured. Any scheme counts (http://, and https+insecure:// from setups
|
||||
# where Codeman itself runs --https), so legacy configs are recognized as ours.
|
||||
ts_serve_443_target_port() {
|
||||
ts_cmd_serve serve status --json 2>/dev/null | node -e '
|
||||
let d = "";
|
||||
process.stdin.on("data", (c) => (d += c));
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const s = JSON.parse(d);
|
||||
for (const [hostport, cfg] of Object.entries(s.Web || {})) {
|
||||
if (!hostport.endsWith(":443")) continue;
|
||||
const proxy = cfg && cfg.Handlers && cfg.Handlers["/"] && cfg.Handlers["/"].Proxy;
|
||||
if (!proxy) continue;
|
||||
const m = String(proxy).match(/:(\d+)\/?$/);
|
||||
if (m) process.stdout.write(m[1]);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# Print https://<node>.<tailnet>.ts.net when tailscale is running AND serve
|
||||
# already forwards 443 to Codeman's port; print nothing otherwise. Safe to call
|
||||
# anywhere (no sudo, no side effects); used by the security notice, uninstall,
|
||||
# and the re-run default.
|
||||
detect_tailscale_serve_url() {
|
||||
check_tailscale || return 0
|
||||
command -v node &>/dev/null || return 0
|
||||
[[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0
|
||||
local port="${CODEMAN_PORT:-3000}"
|
||||
[[ "$(ts_serve_443_target_port)" == "$port" ]] || return 0
|
||||
local dns
|
||||
dns=$(ts_status_field 's.Self && s.Self.DNSName')
|
||||
[[ -n "$dns" ]] || return 0
|
||||
echo "https://${dns%.}"
|
||||
}
|
||||
|
||||
tailscale_retrofit_hint() {
|
||||
warn "$1: falling back to local-only access (127.0.0.1)."
|
||||
echo -e " ${DIM}Set up Tailscale access any time later with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}" >&2
|
||||
}
|
||||
|
||||
offer_install_tailscale() {
|
||||
if [[ "$NONINTERACTIVE" == "1" ]]; then
|
||||
info "Tailscale is not installed; skipping (non-interactive runs never install it)."
|
||||
return 1
|
||||
fi
|
||||
headless_guard "install Tailscale (curl | sh from tailscale.com)"
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
if command -v brew &>/dev/null; then
|
||||
if ! prompt_yes_no "Tailscale is not installed. Install it now with Homebrew?" "y"; then
|
||||
return 1
|
||||
fi
|
||||
if ! brew install --cask tailscale; then
|
||||
warn "Homebrew install failed."
|
||||
return 1
|
||||
fi
|
||||
open -a Tailscale 2>/dev/null || true
|
||||
info "Log in via the Tailscale menu-bar app if it asks."
|
||||
else
|
||||
info "Install the Tailscale app first: https://tailscale.com/download/macos"
|
||||
if ! prompt_yes_no "Continue once Tailscale is installed?" "n"; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if ! prompt_yes_no "Tailscale is not installed. Install it now (official installer from tailscale.com)?" "y"; then
|
||||
return 1
|
||||
fi
|
||||
info "Running the official Tailscale installer (it may ask for sudo)..."
|
||||
# When piped (curl | bash), stdin is our pipe: give the child installer
|
||||
# the real terminal so its own sudo prompt works.
|
||||
if [[ -e /dev/tty ]]; then
|
||||
if ! sh -c "$(download_to_stdout https://tailscale.com/install.sh)" < /dev/tty; then
|
||||
warn "Tailscale installation failed."
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if ! sh -c "$(download_to_stdout https://tailscale.com/install.sh)"; then
|
||||
warn "Tailscale installation failed."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! check_tailscale; then
|
||||
warn "tailscale was not found after the install."
|
||||
return 1
|
||||
fi
|
||||
success "Tailscale installed"
|
||||
return 0
|
||||
}
|
||||
|
||||
ensure_tailscale_login() {
|
||||
local state
|
||||
state=$(ts_status_field 's.BackendState')
|
||||
if [[ "$state" == "Running" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
|
||||
warn "Tailscale is installed but not connected (state: ${state:-unknown})."
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Tailscale needs to log in to your tailnet."
|
||||
echo -e " ${DIM}A login URL will be printed: open it on any device. Waiting up to 5 minutes.${NC}"
|
||||
local ts_bin up_ok="0"
|
||||
ts_bin=$(get_tailscale_path) || return 1
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
# The GUI app's CLI runs as the user; no root needed.
|
||||
if "$ts_bin" up --timeout=300s; then up_ok="1"; fi
|
||||
else
|
||||
if [[ -e /dev/tty ]]; then
|
||||
if run_as_root "$ts_bin" up --timeout=300s < /dev/tty; then up_ok="1"; fi
|
||||
else
|
||||
if run_as_root "$ts_bin" up --timeout=300s; then up_ok="1"; fi
|
||||
fi
|
||||
fi
|
||||
if [[ "$up_ok" != "1" ]]; then
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
info "If the CLI cannot log in, open the Tailscale app, log in there, then run:"
|
||||
info " bash $INSTALL_DIR/install.sh tailscale"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
[[ "$(ts_status_field 's.BackendState')" == "Running" ]]
|
||||
}
|
||||
|
||||
# Linux: `tailscale serve` needs root or operator rights. Grant operator once
|
||||
# (with the user's consent via sudo) so serve config never needs sudo again;
|
||||
# fall back to sudo-per-command when the grant fails.
|
||||
ensure_tailscale_operator() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]] || [[ $EUID -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ts_cmd serve status &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v sudo &>/dev/null; then
|
||||
warn "No sudo available; tailscale serve configuration may fail without root."
|
||||
TS_NEED_ROOT="1"
|
||||
return 0
|
||||
fi
|
||||
info "Granting your user Tailscale 'operator' rights (one-time sudo; lets serve run without root)..."
|
||||
local ts_bin
|
||||
ts_bin=$(get_tailscale_path) || return 0
|
||||
if run_as_root "$ts_bin" set --operator="$USER" 2>/dev/null && ts_cmd serve status &>/dev/null; then
|
||||
success "Operator rights granted"
|
||||
return 0
|
||||
fi
|
||||
warn "Could not grant operator rights; serve commands will use sudo."
|
||||
TS_NEED_ROOT="1"
|
||||
return 0
|
||||
}
|
||||
|
||||
# HTTPS certificates are a per-tailnet admin toggle. Serve without them cannot
|
||||
# terminate TLS, and a plain-HTTP fallback would silently break the "real
|
||||
# HTTPS" promise (PWA install, web push), so guide the user through enabling
|
||||
# them instead of degrading.
|
||||
ensure_tailnet_https() {
|
||||
while true; do
|
||||
local magic cert
|
||||
magic=$(ts_status_field 's.CurrentTailnet && s.CurrentTailnet.MagicDNSEnabled ? "1" : ""')
|
||||
cert=$(ts_status_field 'Array.isArray(s.CertDomains) && s.CertDomains.length > 0 ? "1" : ""')
|
||||
if [[ "$magic" == "1" && "$cert" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
warn "Your tailnet has not enabled HTTPS certificates yet (a one-time admin toggle)."
|
||||
echo -e " Open ${CYAN}https://login.tailscale.com/admin/dns${NC} and enable:" >&2
|
||||
if [[ "$magic" == "1" ]]; then
|
||||
echo -e " ${CYAN}1.${NC} MagicDNS ${GREEN}(already on)${NC}" >&2
|
||||
else
|
||||
echo -e " ${CYAN}1.${NC} MagicDNS" >&2
|
||||
fi
|
||||
if [[ "$cert" == "1" ]]; then
|
||||
echo -e " ${CYAN}2.${NC} HTTPS Certificates ${GREEN}(already on)${NC}" >&2
|
||||
else
|
||||
echo -e " ${CYAN}2.${NC} HTTPS Certificates" >&2
|
||||
fi
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
|
||||
return 1
|
||||
fi
|
||||
if ! prompt_yes_no "Re-check now? (answering no skips Tailscale setup)" "y"; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
setup_tailscale_serve() {
|
||||
local port="${CODEMAN_PORT:-3000}"
|
||||
local dns url existing
|
||||
dns=$(ts_status_field 's.Self && s.Self.DNSName')
|
||||
if [[ -z "$dns" ]]; then
|
||||
warn "Could not determine this machine's tailnet DNS name."
|
||||
return 1
|
||||
fi
|
||||
url="https://${dns%.}"
|
||||
|
||||
existing=$(ts_serve_443_target_port)
|
||||
if [[ "$existing" == "$port" ]]; then
|
||||
TAILSCALE_SERVE_URL="$url"
|
||||
success "Tailscale serve already forwards $url to port $port (kept as-is)"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$existing" ]]; then
|
||||
warn "tailscale serve already forwards $url (port 443) to local port $existing."
|
||||
if ! prompt_yes_no "Replace that mapping with Codeman (port $port)?" "n"; then
|
||||
info "Keeping the existing mapping."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
info "Configuring: tailscale serve --bg $port"
|
||||
local serve_out
|
||||
if serve_out=$(ts_cmd_serve serve --bg "$port" 2>&1); then
|
||||
TAILSCALE_SERVE_URL="$url"
|
||||
success "Tailscale HTTPS enabled: $url"
|
||||
echo -e " ${DIM}(persists across reboots; inspect with: tailscale serve status)${NC}"
|
||||
return 0
|
||||
fi
|
||||
warn "tailscale serve failed:"
|
||||
printf '%s\n' "$serve_out" | sed 's/^/ /' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Curl the ts.net URL until it answers. 200 = reachable; 401 = reachable behind
|
||||
# the dashboard password. The first request can be slow while tailscaled
|
||||
# obtains the Let's Encrypt certificate.
|
||||
verify_tailscale_access() {
|
||||
if [[ -z "$TAILSCALE_SERVE_URL" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v curl &>/dev/null; then
|
||||
info "curl not available; open $TAILSCALE_SERVE_URL to verify."
|
||||
return 0
|
||||
fi
|
||||
info "Verifying $TAILSCALE_SERVE_URL (first load can take ~30s while the HTTPS certificate is issued)..."
|
||||
local i http_code
|
||||
for ((i = 1; i <= 10; i++)); do
|
||||
http_code=$(curl -skm 10 -o /dev/null -w '%{http_code}' "$TAILSCALE_SERVE_URL/api/status" 2>/dev/null) || http_code=""
|
||||
if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then
|
||||
success "Reachable: $TAILSCALE_SERVE_URL"
|
||||
return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
warn "Could not reach $TAILSCALE_SERVE_URL/api/status yet."
|
||||
warn "It may need another minute (certificate issuance). Inspect: tailscale serve status"
|
||||
warn "If Codeman itself runs with --https, the serve target must be:"
|
||||
warn " tailscale serve --bg https+insecure://localhost:${CODEMAN_PORT:-3000}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Orchestrator: walk every state (not installed -> logged out -> operator ->
|
||||
# tailnet HTTPS -> serve) and end with TAILSCALE_SERVE_URL set, or fall back
|
||||
# gracefully (the caller keeps the loopback bind either way).
|
||||
setup_tailscale_access() {
|
||||
TAILSCALE_SERVE_URL=""
|
||||
if ! check_tailscale; then
|
||||
if ! offer_install_tailscale; then
|
||||
tailscale_retrofit_hint "Tailscale is not installed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if ! command -v node &>/dev/null; then
|
||||
tailscale_retrofit_hint "node is not on PATH yet"
|
||||
return 1
|
||||
fi
|
||||
if ! ensure_tailscale_login; then
|
||||
tailscale_retrofit_hint "Tailscale is not connected"
|
||||
return 1
|
||||
fi
|
||||
ensure_tailscale_operator
|
||||
if ! ensure_tailnet_https; then
|
||||
tailscale_retrofit_hint "HTTPS certificates are not enabled for your tailnet"
|
||||
return 1
|
||||
fi
|
||||
if ! setup_tailscale_serve; then
|
||||
tailscale_retrofit_hint "tailscale serve could not be configured"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# `install.sh tailscale`: retrofit Tailscale access onto an existing install
|
||||
# (also the target of every "set it up later" hint above).
|
||||
setup_tailscale_subcommand() {
|
||||
print_banner
|
||||
if ! command -v node &>/dev/null; then
|
||||
die "node is required. Install Codeman first (run the installer without arguments)."
|
||||
fi
|
||||
|
||||
read_existing_binding
|
||||
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
|
||||
warn "Your service binds $EXISTING_HOST (network-wide). Tailscale serve will work, but the"
|
||||
warn "dashboard stays reachable on your LAN too. Re-run the installer and choose Tailscale"
|
||||
warn "to switch to the tighter loopback-only bind."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if ! setup_tailscale_access; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify end-to-end only when Codeman is actually answering locally.
|
||||
local port="${CODEMAN_PORT:-3000}" server_up="0"
|
||||
if command -v curl &>/dev/null; then
|
||||
if curl -skm 5 -o /dev/null "http://127.0.0.1:$port/api/status" 2>/dev/null ||
|
||||
curl -skm 5 -o /dev/null "https://127.0.0.1:$port/api/status" 2>/dev/null; then
|
||||
server_up="1"
|
||||
fi
|
||||
fi
|
||||
if [[ "$server_up" == "1" ]]; then
|
||||
verify_tailscale_access || true
|
||||
else
|
||||
info "Codeman does not appear to be running on port $port right now."
|
||||
info "Once it is, open: $TAILSCALE_SERVE_URL"
|
||||
fi
|
||||
|
||||
BIND_HOST="${EXISTING_HOST:-127.0.0.1}"
|
||||
BIND_PASSWORD="$EXISTING_PASSWORD"
|
||||
print_security_notice
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Service Setup (Linux systemd / macOS launchd)
|
||||
# ============================================================================
|
||||
@@ -1773,10 +2279,19 @@ main() {
|
||||
|
||||
echo ""
|
||||
if [[ "$service_ok" == "true" ]]; then
|
||||
# With Tailscale configured, prove the URL actually answers now
|
||||
# that the server is up (never claim success blindly).
|
||||
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
|
||||
verify_tailscale_access || true
|
||||
echo ""
|
||||
fi
|
||||
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Open in browser${NC}"
|
||||
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
|
||||
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
|
||||
echo -e " $TAILSCALE_SERVE_URL ${DIM}(any device on your tailnet, HTTPS)${NC}"
|
||||
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
|
||||
elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then
|
||||
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
|
||||
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
|
||||
else
|
||||
@@ -1822,10 +2337,21 @@ main() {
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Open in browser${NC}"
|
||||
echo -e " http://localhost:3000"
|
||||
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
|
||||
echo -e " $TAILSCALE_SERVE_URL ${DIM}(any device on your tailnet, once running)${NC}"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
|
||||
echo -e " ${BOLD}Remote Access (Tailscale):${NC}"
|
||||
echo ""
|
||||
echo -e " $TAILSCALE_SERVE_URL ${DIM}(HTTPS, any device on your tailnet)${NC}"
|
||||
echo -e " ${CYAN}tailscale serve status${NC} # Inspect the mapping"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if check_cloudflared; then
|
||||
echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}"
|
||||
echo ""
|
||||
@@ -1982,6 +2508,20 @@ uninstall() {
|
||||
success "Removed LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Remove OUR tailscale serve mapping (443 -> Codeman's port) only. Other
|
||||
# serve config stays untouched, and never `tailscale serve reset`.
|
||||
local ts_url=""
|
||||
ts_url=$(detect_tailscale_serve_url 2>/dev/null) || ts_url=""
|
||||
if [[ -n "$ts_url" ]]; then
|
||||
if prompt_yes_no "Remove the Tailscale serve mapping for Codeman ($ts_url)?" "y"; then
|
||||
if ts_cmd_serve serve --https=443 off 2>/dev/null; then
|
||||
success "Removed tailscale serve mapping"
|
||||
else
|
||||
warn "Could not remove it automatically. Run: tailscale serve --https=443 off"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove symlinks
|
||||
local symlink_dir="$HOME/.local/bin"
|
||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||
@@ -2030,6 +2570,7 @@ uninstall() {
|
||||
case "${1:-}" in
|
||||
update) update ;;
|
||||
uninstall) uninstall ;;
|
||||
tailscale) setup_tailscale_subcommand ;;
|
||||
*)
|
||||
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
||||
# (clone succeeded but build/menu never finished) lacks the marker and
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.5",
|
||||
"version": "1.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.5",
|
||||
"version": "1.11.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -12333,7 +12333,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.5",
|
||||
"version": "1.11.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -22,6 +22,7 @@
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"test:ci": "vitest run --config config/vitest.ci.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
@@ -156,6 +157,7 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/postinstall.js",
|
||||
"scripts/fix-node-pty.mjs",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Fixed: sessions failed to start on macOS with `Error: posix_spawnp failed.`** (issues #6 and #204)
|
||||
|
||||
`node-pty@1.1.0` publishes its macOS prebuilt helper as `prebuilds/darwin-<arch>/spawn-helper` with mode 0644, i.e. no execute bit. macOS launches every PTY through that helper, so a stock install failed on every session start. The bug is macOS-only: `spawn-helper` is a mac-only gyp target and node-pty ships no Linux prebuild, so Linux always compiles a correctly-permissioned helper from source.
|
||||
|
||||
The previous fix chmodded only `build/Release/spawn-helper`, which on macOS does not exist (the prebuild is used, so node-gyp never runs), and it derived that path from `require.resolve('node-pty')`, landing on `<pkg>/lib/build/Release/...`. It was a no-op on every platform.
|
||||
- New `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every `spawn-helper` it finds, in `build/Release`, `build/Debug` and each `prebuilds/*/`, then verifies the result by actually opening a PTY. A `require()` alone passes on a broken install, because the helper is only touched at spawn time.
|
||||
- `postinstall` no longer force-rebuilds node-pty from source on Node 22+. That step needed Xcode command line tools, cost 30-120s on every install, and deleted the `prebuilds/` tree before compiling, so a Mac without a compiler was left with no working binary at all. A rebuild now happens only when the chmod plus spawn probe still fails, and the prebuilds tree is backed up and restored around it.
|
||||
- New `spawnPtyWithHelperRepair()` (`src/utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts`, so an install that is already broken repairs itself on the first failed spawn and retries in-process instead of showing a dead session. Unrelated spawn errors are rethrown untouched; a second failure carries the `npm run fix:node-pty` hint.
|
||||
- `scripts/fix-node-pty.mjs` is now in the published `files` list, so global npm installs get the repair too.
|
||||
- Direct-PTY Claude spawns use the resolved absolute binary path (new `getClaudeBinaryPath()`) instead of the bare name `claude`, so a CLI installed outside the server's PATH still launches.
|
||||
|
||||
Verified end to end on macOS 26.4 arm64: a stock `npm i` reproduces `posix_spawnp failed.`, and after the fix the same install spawns a PTY successfully with the prebuilds preserved.
|
||||
|
||||
**Added: phone home screen (session overview)**
|
||||
|
||||
Under 430px the "C" logo now opens a session overview (current sessions, past sessions, spaces) instead of the welcome overlay: on a small screen "which session needs me" beats "how do I start one". Rows resume a session in place, and "New session here" goes through the normal quick-start path so remote and Docker cases keep their routing. Per-device setting `mobileOverviewEnabled` (phones only, default ON) in App Settings. Tablet and desktop are unchanged.
|
||||
|
||||
**Added: guided Tailscale setup in `install.sh`**
|
||||
|
||||
The network-access prompt is now 3-way: Tailscale, LAN, or local-only. The Tailscale path binds loopback and walks through installing Tailscale, logging in, the operator grant, the tailnet HTTPS-certificates toggle, and `tailscale serve --bg <port>`, then verifies the result end to end with curl. That gives HTTPS on a real certificate with no app password and no `0.0.0.0` bind, which is also what PWA install and web push need. `install.sh tailscale` retrofits it onto an existing install, and `CODEMAN_TAILSCALE=1` presets the choice. Serve state is detected from `tailscale serve status --json`; the installer never runs `tailscale serve reset` and never touches serve mappings other than 443 to Codeman's port. README and `docs/security-architecture.md` updated to match.
|
||||
|
||||
**Docs**: replaced a real tailnet hostname with placeholders in `docs/web-tabs-fixes-plan.md`.
|
||||
|
||||
**xterm-zerolag-input**: npm description and keywords only, no code change.
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.1.7",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
|
||||
"version": "0.1.8",
|
||||
"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",
|
||||
"module": "dist/index.js",
|
||||
@@ -26,8 +26,16 @@
|
||||
"xterm",
|
||||
"xterm.js",
|
||||
"terminal",
|
||||
"web-terminal",
|
||||
"local-echo",
|
||||
"local echo",
|
||||
"mosh",
|
||||
"input-latency",
|
||||
"latency",
|
||||
"zero-lag",
|
||||
"keystroke",
|
||||
"ssh",
|
||||
"remote-terminal",
|
||||
"overlay",
|
||||
"addon"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @fileoverview Repairs node-pty's macOS `spawn-helper` and verifies that a PTY
|
||||
* can really be spawned. Called by `scripts/postinstall.js` on every install and
|
||||
* exposed as `npm run fix:node-pty` for repairing an install after the fact.
|
||||
*
|
||||
* Why this exists (issues #6 and #204):
|
||||
*
|
||||
* node-pty@1.1.0 publishes its macOS prebuilt helper as
|
||||
* `prebuilds/darwin-<arch>/spawn-helper` with mode 0644, i.e. no execute bit.
|
||||
* On macOS node-pty launches every PTY through that helper with posix_spawnp,
|
||||
* which then fails EACCES and surfaces as `Error: posix_spawnp failed.` on every
|
||||
* session start.
|
||||
*
|
||||
* It is macOS-exclusive twice over: `spawn-helper` is an `OS=="mac"` gyp target,
|
||||
* and pty.cc only spawns it under `#if defined(__APPLE__)`. node-pty ships
|
||||
* prebuilds for darwin and win32 only, so Linux always compiles from source
|
||||
* (which produces an executable helper) and never sees the bug.
|
||||
*
|
||||
* The repair is a chmod, NOT a rebuild: the prebuilt binary itself is fine, and
|
||||
* requiring a from-source rebuild would make every macOS install depend on Xcode
|
||||
* command line tools. A rebuild is attempted only when a chmod plus a real spawn
|
||||
* probe still can't get a working PTY, and the prebuilds tree is backed up first
|
||||
* so a failed rebuild can never leave the install worse than it started.
|
||||
*/
|
||||
|
||||
import { chmodSync, cpSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** Errors that mean "the native module or its helper is unusable", i.e. worth a rebuild. */
|
||||
const NATIVE_FAILURE_PATTERN = /posix_spawnp|spawn-helper|Failed to load native module|Cannot find module/i;
|
||||
|
||||
/**
|
||||
* Locates the installed node-pty package directory.
|
||||
*
|
||||
* @returns {string|null} Absolute path to the package root, or null if not installed.
|
||||
*/
|
||||
export function findNodePtyDir() {
|
||||
// package.json first: node-pty declares no "exports" map, so the subpath resolves,
|
||||
// and it lands on the package root directly. require.resolve('node-pty') would give
|
||||
// <pkg>/lib/index.js, which is one directory deeper than callers expect.
|
||||
try {
|
||||
return dirname(require.resolve('node-pty/package.json'));
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
return join(dirname(require.resolve('node-pty')), '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every `spawn-helper` shipped in a node-pty install.
|
||||
*
|
||||
* node-pty's own loader (lib/utils.js) checks `build/Release`, `build/Debug` and
|
||||
* then `prebuilds/<platform>-<arch>`, and takes the helper from whichever
|
||||
* directory the native module loaded out of, so all of them must be executable,
|
||||
* not just the one this machine happens to use today.
|
||||
*
|
||||
* @param {string} ptyDir Absolute path to the node-pty package root.
|
||||
* @returns {string[]} Absolute paths of the helpers that exist on disk.
|
||||
*/
|
||||
export function listSpawnHelpers(ptyDir) {
|
||||
const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')];
|
||||
|
||||
const prebuilds = join(ptyDir, 'prebuilds');
|
||||
if (existsSync(prebuilds)) {
|
||||
try {
|
||||
for (const entry of readdirSync(prebuilds, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name));
|
||||
}
|
||||
} catch {
|
||||
/* unreadable prebuilds dir: nothing to repair there */
|
||||
}
|
||||
}
|
||||
|
||||
return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the execute bit to every `spawn-helper` that is missing it.
|
||||
*
|
||||
* @param {string} ptyDir Absolute path to the node-pty package root.
|
||||
* @returns {{ repaired: string[], failed: Array<{ path: string, error: string }> }}
|
||||
*/
|
||||
export function repairSpawnHelpers(ptyDir) {
|
||||
const repaired = [];
|
||||
const failed = [];
|
||||
|
||||
for (const helper of listSpawnHelpers(ptyDir)) {
|
||||
try {
|
||||
const mode = statSync(helper).mode & 0o777;
|
||||
if ((mode & 0o111) === 0o111) continue; // already executable by all
|
||||
chmodSync(helper, mode | 0o755);
|
||||
repaired.push(helper);
|
||||
} catch (err) {
|
||||
failed.push({ path: helper, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { repaired, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves node-pty works by actually opening a PTY, which is the only check that
|
||||
* exercises the spawn-helper path that breaks. A `require` alone would pass on a
|
||||
* broken install, because the helper is only touched at spawn time.
|
||||
*
|
||||
* @param {string} ptyDir Absolute path to the node-pty package root.
|
||||
* @returns {{ ok: boolean, error?: string, nativeFailure?: boolean }}
|
||||
*/
|
||||
export function verifyPtySpawn(ptyDir) {
|
||||
let child;
|
||||
try {
|
||||
const pty = require(ptyDir); // directory require → node-pty's "main" (lib/index.js)
|
||||
const file = process.platform === 'win32' ? process.env.COMSPEC || 'cmd.exe' : '/bin/echo';
|
||||
const args = process.platform === 'win32' ? ['/c', 'exit'] : ['codeman-node-pty-check'];
|
||||
child = pty.spawn(file, args, {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: tmpdir(),
|
||||
env: process.env,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: message, nativeFailure: NATIVE_FAILURE_PATTERN.test(message) };
|
||||
} finally {
|
||||
try {
|
||||
child?.kill();
|
||||
} catch {
|
||||
/* the probe child exits on its own anyway */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds node-pty from source, preserving the prebuilds tree across a failure.
|
||||
*
|
||||
* node-pty's install script deletes `prebuilds/` as soon as
|
||||
* `npm_config_build_from_source` is set and only then shells out to node-gyp, so
|
||||
* a machine without a compiler toolchain would otherwise be left with neither a
|
||||
* prebuilt nor a compiled binary.
|
||||
*
|
||||
* @param {string} ptyDir Absolute path to the node-pty package root.
|
||||
* @param {string} cwd Directory to run npm from (the package root that owns node_modules).
|
||||
* @returns {{ ok: boolean, error?: string }}
|
||||
*/
|
||||
function rebuildFromSource(ptyDir, cwd) {
|
||||
const prebuilds = join(ptyDir, 'prebuilds');
|
||||
const backup = join(ptyDir, '.prebuilds-codeman-backup');
|
||||
|
||||
let backedUp = false;
|
||||
if (existsSync(prebuilds)) {
|
||||
try {
|
||||
rmSync(backup, { recursive: true, force: true });
|
||||
cpSync(prebuilds, backup, { recursive: true });
|
||||
backedUp = true;
|
||||
} catch {
|
||||
/* best effort: proceed without a safety net rather than skip the repair */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('npm rebuild node-pty --build-from-source', { cwd, stdio: 'pipe', timeout: 300000 });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (backedUp && !existsSync(prebuilds)) {
|
||||
try {
|
||||
cpSync(backup, prebuilds, { recursive: true });
|
||||
} catch {
|
||||
/* nothing further we can do */
|
||||
}
|
||||
}
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full repair flow: chmod, verify, and only rebuild if a working PTY still can't
|
||||
* be opened.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {(line: string) => void} [options.log] Progress sink (default: silent).
|
||||
* @param {(line: string) => void} [options.warn] Warning sink (default: same as log).
|
||||
* @param {boolean} [options.allowRebuild] Permit a from-source rebuild (default: true).
|
||||
* @returns {Promise<{ ok: boolean, repaired: string[], rebuilt: boolean, reason?: string }>}
|
||||
*/
|
||||
export async function fixNodePty(options = {}) {
|
||||
const log = options.log ?? (() => {});
|
||||
const warn = options.warn ?? log;
|
||||
const allowRebuild = options.allowRebuild ?? true;
|
||||
|
||||
const ptyDir = findNodePtyDir();
|
||||
if (!ptyDir) {
|
||||
return { ok: false, repaired: [], rebuilt: false, reason: 'node-pty is not installed' };
|
||||
}
|
||||
|
||||
const { repaired, failed } = repairSpawnHelpers(ptyDir);
|
||||
for (const f of failed) warn(`could not chmod ${f.path}: ${f.error}`);
|
||||
if (repaired.length > 0) {
|
||||
log(`made node-pty spawn-helper executable (${repaired.length} file${repaired.length === 1 ? '' : 's'})`);
|
||||
}
|
||||
|
||||
const first = verifyPtySpawn(ptyDir);
|
||||
if (first.ok) return { ok: true, repaired, rebuilt: false };
|
||||
|
||||
if (!allowRebuild || !first.nativeFailure) {
|
||||
return { ok: false, repaired, rebuilt: false, reason: first.error };
|
||||
}
|
||||
|
||||
warn(`node-pty could not open a PTY (${first.error}), rebuilding from source...`);
|
||||
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const rebuild = rebuildFromSource(ptyDir, projectRoot);
|
||||
if (!rebuild.ok) {
|
||||
return { ok: false, repaired, rebuilt: false, reason: `rebuild failed: ${rebuild.error}` };
|
||||
}
|
||||
|
||||
const after = repairSpawnHelpers(ptyDir);
|
||||
repaired.push(...after.repaired);
|
||||
|
||||
const second = verifyPtySpawn(ptyDir);
|
||||
return second.ok
|
||||
? { ok: true, repaired, rebuilt: true }
|
||||
: { ok: false, repaired, rebuilt: true, reason: second.error };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI: node scripts/fix-node-pty.mjs [--quiet]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
if (isDirectRun) {
|
||||
const quiet = process.argv.includes('--quiet');
|
||||
const say = (line) => {
|
||||
if (!quiet) console.log(line);
|
||||
};
|
||||
|
||||
const result = await fixNodePty({ log: say, warn: (line) => console.warn(line) });
|
||||
|
||||
if (result.ok) {
|
||||
say(result.repaired.length > 0 || result.rebuilt ? 'node-pty repaired, PTY spawning works' : 'node-pty is healthy');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`node-pty is not usable: ${result.reason}`);
|
||||
console.error('Try: cd node_modules/node-pty && npx node-gyp rebuild');
|
||||
process.exit(1);
|
||||
}
|
||||
+21
-24
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { chmodSync, existsSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import { homedir, platform } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
@@ -148,35 +148,32 @@ if (majorVersion < MIN_NODE_VERSION) {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1b. Fix node-pty spawn-helper permissions (macOS posix_spawnp fix)
|
||||
// 1b. Repair + verify node-pty (macOS posix_spawnp fix, issues #6 and #204)
|
||||
//
|
||||
// node-pty ships its macOS spawn-helper without the execute bit, which breaks
|
||||
// every session start on macOS. fixNodePty() chmods it, then proves a PTY can
|
||||
// actually be opened, and only falls back to a from-source rebuild if that
|
||||
// still fails. See scripts/fix-node-pty.mjs for the full story.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const ptyPath = join(require.resolve('node-pty'), '..');
|
||||
const spawnHelper = join(ptyPath, 'build', 'Release', 'spawn-helper');
|
||||
if (existsSync(spawnHelper)) {
|
||||
chmodSync(spawnHelper, 0o755);
|
||||
console.log(colors.green('✓ node-pty spawn-helper permissions fixed'));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — only affects macOS with prebuilt binaries
|
||||
}
|
||||
const { fixNodePty } = await import('./fix-node-pty.mjs');
|
||||
const result = await fixNodePty({
|
||||
log: (line) => console.log(colors.dim(` ${line}`)),
|
||||
warn: (line) => console.log(colors.yellow(`⚠ ${line}`)),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1c. Rebuild node-pty from source for Node.js 22+ compatibility
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (majorVersion >= 22) {
|
||||
try {
|
||||
console.log(colors.dim(' Rebuilding node-pty from source for Node.js 22+...'));
|
||||
execSync('npm rebuild node-pty --build-from-source', { stdio: 'pipe', timeout: 120000 });
|
||||
console.log(colors.green('✓ node-pty rebuilt from source'));
|
||||
} catch {
|
||||
if (result.ok) {
|
||||
console.log(colors.green('✓ node-pty verified') + colors.dim(' (PTY spawn works)'));
|
||||
} else {
|
||||
hasWarnings = true;
|
||||
console.log(colors.yellow('⚠ Failed to rebuild node-pty from source'));
|
||||
console.log(colors.dim(' You may need to run: npm rebuild node-pty --build-from-source'));
|
||||
console.log(colors.yellow(`⚠ node-pty is not usable: ${result.reason}`));
|
||||
console.log(colors.dim(' Sessions will fail to start. Try: ') + colors.cyan('npm run fix:node-pty'));
|
||||
}
|
||||
} catch (err) {
|
||||
hasWarnings = true;
|
||||
console.log(colors.yellow(`⚠ Could not verify node-pty: ${err.message}`));
|
||||
console.log(colors.dim(' If sessions fail to start, run: ') + colors.cyan('npm run fix:node-pty'));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -98,6 +98,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
usedBy: ['Gemini sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Antigravity sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview File Viewer edit-mode policy (issue #212).
|
||||
*
|
||||
* Pure, IO-free policy for which workspace files the in-viewer editor may read
|
||||
* for editing and write back. Consumed by the `edit=1` branch of
|
||||
* `GET /api/sessions/:id/file-content` and by `PUT /api/sessions/:id/file-content`
|
||||
* in `src/web/routes/file-routes.ts`.
|
||||
*
|
||||
* Design (docs/file-viewer-edit-plan.md):
|
||||
* - ALLOWLIST of text extensions/basenames, not a blocklist — matching the
|
||||
* attachment-guard precedent. `svg` and `env` are deliberately absent: svg is
|
||||
* treated as untrusted on the read side, and `.env` is sensitive-path blocked
|
||||
* anyway; excluding them here keeps a single obvious refusal.
|
||||
* - The `.git/` subtree is denied outright: `.git/hooks/*` is code execution and
|
||||
* a corrupted index looks unrecoverable to a user who wanted to fix a typo.
|
||||
* - EOL helpers exist because a browser <textarea> normalizes to LF; the server
|
||||
* re-applies the file's original ending so a two-line edit of a CRLF file does
|
||||
* not become a whole-file diff. Mixed-EOL files normalize to the dominant
|
||||
* style (documented lossy edge).
|
||||
*/
|
||||
|
||||
/** Hard cap for edit-mode reads AND writes (bytes of file content). */
|
||||
export const MAX_EDITABLE_BYTES = 512 * 1024;
|
||||
|
||||
/** Lowercase extensions (no dot) the editor will open and save. */
|
||||
export const EDITABLE_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
// JS/TS ecosystem
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'json',
|
||||
'jsonc',
|
||||
// Docs / plain text
|
||||
'md',
|
||||
'mdx',
|
||||
'txt',
|
||||
'rst',
|
||||
'adoc',
|
||||
// Web
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'html',
|
||||
'htm',
|
||||
'xml',
|
||||
// Config
|
||||
'yml',
|
||||
'yaml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'properties',
|
||||
// Shell
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
// Languages
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'swift',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'cc',
|
||||
'cs',
|
||||
'php',
|
||||
'sql',
|
||||
'graphql',
|
||||
'proto',
|
||||
'lua',
|
||||
'pl',
|
||||
'r',
|
||||
'jl',
|
||||
'tf',
|
||||
'gradle',
|
||||
// Data / misc text
|
||||
'csv',
|
||||
'tsv',
|
||||
'log',
|
||||
'diff',
|
||||
'patch',
|
||||
]);
|
||||
|
||||
/** Extensionless (or dot-led) file names that are still editable text. */
|
||||
export const EDITABLE_BASENAMES: ReadonlySet<string> = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'license',
|
||||
'readme',
|
||||
'changelog',
|
||||
'authors',
|
||||
'codeowners',
|
||||
'procfile',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.dockerignore',
|
||||
'.prettierignore',
|
||||
'.prettierrc',
|
||||
'.editorconfig',
|
||||
'.nvmrc',
|
||||
'.npmrc',
|
||||
'.eslintignore',
|
||||
]);
|
||||
|
||||
/** Whether a file name (basename only) is eligible for in-viewer editing. */
|
||||
export function isEditableFileName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (EDITABLE_BASENAMES.has(lower)) return true;
|
||||
const dot = lower.lastIndexOf('.');
|
||||
// No extension (or a bare dotfile like `.bashrc`): only the basename list applies.
|
||||
if (dot <= 0) return false;
|
||||
return EDITABLE_EXTENSIONS.has(lower.slice(dot + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a workspace-relative path is denied for editing regardless of its
|
||||
* extension. Currently: anything inside a `.git` directory at any depth.
|
||||
*/
|
||||
export function isDeniedEditRelativePath(relativePath: string): boolean {
|
||||
return relativePath.split('/').some((segment) => segment === '.git');
|
||||
}
|
||||
|
||||
export type FileEol = 'lf' | 'crlf';
|
||||
|
||||
/** Dominant line-ending style of a text buffer (LF when tied or single-line). */
|
||||
export function detectEol(text: string): FileEol {
|
||||
let crlf = 0;
|
||||
let lf = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) {
|
||||
if (i > 0 && text.charCodeAt(i - 1) === 13) crlf++;
|
||||
else lf++;
|
||||
}
|
||||
}
|
||||
return crlf > lf ? 'crlf' : 'lf';
|
||||
}
|
||||
|
||||
/** Normalize every line ending in `text` to the requested style. */
|
||||
export function applyEol(text: string, eol: FileEol): string {
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
return eol === 'crlf' ? normalized.replace(/\n/g, '\r\n') : normalized;
|
||||
}
|
||||
@@ -143,6 +143,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
antigravity: 'exec agy',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CodexConfig,
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
AntigravityConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -74,6 +75,7 @@ export interface CreateSessionOptions {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
@@ -102,6 +104,7 @@ export interface RespawnPaneOptions {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
|
||||
+50
-5
@@ -58,16 +58,61 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
|
||||
await writeJsonArray(configDir, remoteCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote user's login shell, defaulted and quoted.
|
||||
*
|
||||
* The default is belt-and-braces, not a live bug: an empty `$SHELL` would expand
|
||||
* to `exec -i -l`, which the shell reads as `exec -i` — "not found", pane dead on
|
||||
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
|
||||
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
|
||||
* SHELL into a pane from its own `default-shell` option, so the command as USED
|
||||
* here is safe either way (also verified). The default matters because these
|
||||
* strings are the seed values a per-host `commands.*` override is edited from, and
|
||||
* nothing constrains where an edited one ends up running. Quoted for a shell path
|
||||
* containing spaces. `/bin/sh` exists on every POSIX host.
|
||||
*/
|
||||
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
||||
|
||||
/**
|
||||
* Run `command` through the remote user's interactive login shell, so per-user
|
||||
* PATH entries (~/.local/bin, ~/.opencode/bin, …) are resolved before the CLI name
|
||||
* is looked up. ssh's remote-command execution is neither interactive nor login,
|
||||
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
|
||||
* "command not found" (exit 127).
|
||||
*
|
||||
* Shells that take neither flag (nushell, elvish, …) cannot be detected from here
|
||||
* the way `loginShellArgs()` detects them locally, since the shell is whatever the
|
||||
* REMOTE passwd says. A host like that is what the per-host `commands.*` override
|
||||
* is for.
|
||||
*/
|
||||
export function remoteLoginShellCommand(command: string): string {
|
||||
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
|
||||
}
|
||||
|
||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
// Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed
|
||||
// under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by
|
||||
// the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's
|
||||
// remote-command execution is neither interactive nor login, so a bare `exec
|
||||
// claude` sees only sshd's minimal default PATH and fails with "command not
|
||||
// found" (exit 127) — confirmed via `tmux capture-pane` on the
|
||||
// remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same
|
||||
// fix already used for shell mode below, so PATH is fully resolved before the
|
||||
// CLI name is looked up.
|
||||
const commands: Record<RemoteCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
|
||||
// /etc/passwd entry, so this launches their actual login shell (zsh,
|
||||
// fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching
|
||||
// the local shell-mode launch.
|
||||
shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`,
|
||||
// Mirror the LOCAL claude default so the remote agent runs non-interactively
|
||||
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
||||
// per-host `commands.claude` override stays the escape hatch.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
|
||||
opencode: remoteLoginShellCommand('opencode'),
|
||||
codex: remoteLoginShellCommand('codex'),
|
||||
gemini: remoteLoginShellCommand('gemini'),
|
||||
antigravity: remoteLoginShellCommand('agy'),
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
||||
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||
//
|
||||
// The workingDir guess is a last resort and MUST be skipped for any item that
|
||||
// already has its own 'history' entry (step 1 above already gave it a real,
|
||||
// direct scan of its own transcript). Without this guard, a history row whose
|
||||
// OWN extraction genuinely failed (oversized first message, etc.) silently
|
||||
// inherited the newest OTHER session's opening line from the same directory —
|
||||
// not a blank, but actively wrong: old sessions displayed today's conversation
|
||||
// as if it were their own. A row with no 'history' source at all (its
|
||||
// transcript hasn't been linked/scanned under its own id yet) has no such
|
||||
// direct attempt to prefer, so the guess remains a reasonable stand-in there.
|
||||
const firstPromptByUuid = new Map<string, string>();
|
||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
||||
@@ -254,12 +264,13 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
}
|
||||
}
|
||||
for (const item of map.values()) {
|
||||
const hasOwnHistoryEntry = item.sources.includes('history');
|
||||
if (!item.firstPrompt) {
|
||||
// never overwrite an existing non-empty prompt
|
||||
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.firstPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
} else if (item.workingDir && !hasOwnHistoryEntry) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
}
|
||||
@@ -268,7 +279,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.lastPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
} else if (item.workingDir && !hasOwnHistoryEntry) {
|
||||
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
|
||||
+90
-48
@@ -49,6 +49,7 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -65,6 +66,9 @@ import {
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
resolveLocalShell,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -140,7 +144,7 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity';
|
||||
}
|
||||
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
@@ -151,6 +155,8 @@ function getModeLabel(mode: SessionMode): string {
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'antigravity':
|
||||
return 'Antigravity';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
@@ -403,6 +409,8 @@ export class Session extends EventEmitter {
|
||||
private _codexConfig: CodexConfig | undefined;
|
||||
// Gemini configuration (only for mode === 'gemini')
|
||||
private _geminiConfig: GeminiConfig | undefined;
|
||||
// Antigravity configuration (only for mode === 'antigravity')
|
||||
private _antigravityConfig: AntigravityConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -489,6 +497,8 @@ export class Session extends EventEmitter {
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Antigravity configuration (only for mode === 'antigravity') */
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
@@ -499,6 +509,8 @@ export class Session extends EventEmitter {
|
||||
tmuxHistoryLimit?: number;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
||||
lastSubmitAt?: number;
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
||||
@@ -525,6 +537,12 @@ export class Session extends EventEmitter {
|
||||
this._lastActivityAt = this.createdAt;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
// to the launch id even when re-attaching to a mux session whose CLI has
|
||||
// moved on (a `/clear` before the restart), so this anchor is what lets the
|
||||
// response viewer re-derive the live conversation without waiting for the
|
||||
// user to type again.
|
||||
this._lastSubmitAt = config.lastSubmitAt ?? 0;
|
||||
this._mux = config.mux || null;
|
||||
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || null;
|
||||
@@ -562,6 +580,11 @@ export class Session extends EventEmitter {
|
||||
this._geminiConfig = config.geminiConfig;
|
||||
}
|
||||
|
||||
// Apply Antigravity configuration
|
||||
if (config.antigravityConfig) {
|
||||
this._antigravityConfig = config.antigravityConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -1111,6 +1134,7 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
@@ -1119,6 +1143,7 @@ export class Session extends EventEmitter {
|
||||
// recovery can re-attach.
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
lastSubmitAt: this._lastSubmitAt || undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
// can carry secrets). For disk persistence, session-manager calls
|
||||
@@ -1271,15 +1296,17 @@ export class Session extends EventEmitter {
|
||||
const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand();
|
||||
const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName);
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(attachCommand, attachArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(attachCommand, attachArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
@@ -1343,6 +1370,7 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1515,6 +1543,7 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1596,18 +1625,24 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'gemini') {
|
||||
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// Antigravity sessions require tmux for env override injection via setenv
|
||||
if (this.mode === 'antigravity') {
|
||||
throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||
this._status = 'stopped';
|
||||
@@ -1873,8 +1908,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
// Use user's default shell, falling back to a shell that actually exists.
|
||||
// Shared with the tmux pane command so both paths launch the same binary.
|
||||
const shell = resolveLocalShell();
|
||||
console.log(
|
||||
'[Session] Starting shell session with:',
|
||||
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
@@ -1930,13 +1966,15 @@ export class Session extends EventEmitter {
|
||||
// Fallback to direct PTY if mux is not used
|
||||
if (!this.ptyProcess) {
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildShellEnv(this.id),
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildShellEnv(this.id),
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||
this._status = 'stopped';
|
||||
@@ -2036,14 +2074,16 @@ export class Session extends EventEmitter {
|
||||
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||
this.emit(
|
||||
@@ -2512,26 +2552,28 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
write(data: string): void {
|
||||
this._trackCodexSubmit(data);
|
||||
this._trackSubmit(data);
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codex thread tracking ─────────────────────────────────────────────
|
||||
// When a codex pane last submitted a message (Enter). The response-viewer
|
||||
// correlates this against ~/.codex/history.jsonl entry timestamps to find
|
||||
// the thread the pane is ACTUALLY on — the only signal that survives
|
||||
// /resume, /new and /fork typed inside the codex TUI itself.
|
||||
private _codexLastSubmitAt = 0;
|
||||
// ── Conversation tracking ─────────────────────────────────────────────
|
||||
// When this pane last submitted a message (Enter). The response-viewer
|
||||
// correlates this against the CLI's own history.jsonl entry timestamps to
|
||||
// find the conversation the pane is ACTUALLY on — the only signal that
|
||||
// survives /clear, /resume, /new and /fork typed inside the TUI itself,
|
||||
// none of which announce themselves on the PTY's stdout.
|
||||
private _lastSubmitAt = 0;
|
||||
|
||||
get codexLastSubmitAt(): number {
|
||||
return this._codexLastSubmitAt;
|
||||
/** Wall-clock ms of this pane's last Enter; 0 if it has never submitted. */
|
||||
get lastSubmitAt(): number {
|
||||
return this._lastSubmitAt;
|
||||
}
|
||||
|
||||
private _trackCodexSubmit(data: string): void {
|
||||
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
|
||||
this._codexLastSubmitAt = Date.now();
|
||||
private _trackSubmit(data: string): void {
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
this._lastSubmitAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2588,7 +2630,7 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this._trackCodexSubmit(data);
|
||||
this._trackSubmit(data);
|
||||
if (this._mux && this._muxSession) {
|
||||
return this._mux.sendInput(this.id, data);
|
||||
}
|
||||
|
||||
+102
-7
@@ -43,12 +43,18 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||
import {
|
||||
buildSshConnectionArgs,
|
||||
defaultRemoteCommandForMode,
|
||||
remoteLoginShellCommand,
|
||||
remoteSshTarget,
|
||||
} from './remote-hosts.js';
|
||||
import {
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
@@ -69,6 +75,9 @@ import {
|
||||
resolveOpenCodeDir,
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
resolveAntigravityDir,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
@@ -640,6 +649,10 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
||||
parts.push('--dangerously-bypass-approvals-and-sandbox');
|
||||
}
|
||||
|
||||
if (config?.animations !== undefined) {
|
||||
parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`);
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
@@ -682,6 +695,34 @@ function buildGeminiCommand(config?: GeminiConfig): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Antigravity CLI (agy) command with appropriate flags.
|
||||
*
|
||||
* Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added
|
||||
* when the config explicitly asks for it (the frontend sends it for parity with
|
||||
* Codeman's Claude default; the multi-user clamp strips it for non-granted owners,
|
||||
* and an ABSENT config stays at agy's own prompting default — safe like Codex).
|
||||
*/
|
||||
function buildAntigravityCommand(config?: AntigravityConfig): string {
|
||||
const parts = ['agy'];
|
||||
|
||||
if (config?.dangerouslySkipPermissions) {
|
||||
parts.push('--dangerously-skip-permissions');
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
if (config?.resumeConversationId) {
|
||||
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined;
|
||||
if (safeId) parts.push('--conversation', safeId);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
@@ -709,6 +750,7 @@ export function buildSpawnCommand(options: {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
}): string {
|
||||
@@ -739,7 +781,24 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'gemini') {
|
||||
return buildGeminiCommand(options.geminiConfig);
|
||||
}
|
||||
return '$SHELL';
|
||||
if (options.mode === 'antigravity') {
|
||||
return buildAntigravityCommand(options.antigravityConfig);
|
||||
}
|
||||
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
||||
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
||||
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
||||
// SERVER process's env — empty in containers and system systemd units, leaving
|
||||
// the pane command ending in a dangling `&&` ("syntax error: unexpected end of
|
||||
// file", pane dead on arrival). Resolve it in Node and quote the result.
|
||||
// #209: launch it as a LOGIN shell, which is what tmux itself does for a pane
|
||||
// with no `default-command`, so a Codeman shell tab matches a hand-started tmux
|
||||
// one. That is what picks up /etc/profile and /etc/profile.d/* — a systemd
|
||||
// --user service never sourced them, so its PATH is what every pane inherited.
|
||||
// The flags come from loginShellArgs() rather than being hardcoded: they are
|
||||
// appended to a path that ultimately comes from the passwd entry, and a shell
|
||||
// that rejects an unknown flag exits on the spot, which is #208 all over again.
|
||||
const shell = resolveLocalShell();
|
||||
return `${shellescape(shell)}${loginShellArgs(shell)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -811,13 +870,14 @@ export function buildRemoteLaunchCommand(options: {
|
||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
||||
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
|
||||
// command is byte-identical to before. Non-claude modes are unchanged.
|
||||
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
|
||||
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
|
||||
// only an interactive login shell resolves (see that function's comment).
|
||||
const override = remote.commands?.[mode];
|
||||
const modeCommand = override
|
||||
? override
|
||||
: mode === 'claude'
|
||||
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
|
||||
? remoteLoginShellCommand(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)
|
||||
: defaultRemoteCommandForMode(mode);
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
@@ -843,6 +903,24 @@ export function buildRemoteLaunchCommand(options: {
|
||||
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
|
||||
// remote tmux server's other sessions keep their own sizing behavior.
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
// #210: keep a CRASHED pane so the failure is still on screen. Without this,
|
||||
// tmux destroys the pane -> window -> session (and, being the only session,
|
||||
// the whole remote server) the instant the pane command exits, which tears the
|
||||
// local `ssh -t` attach down with it; reconnect's `-A` then builds a fresh
|
||||
// session and the cycle can repeat as a flap loop with no evidence surviving.
|
||||
// That is how the exit-127 PATH bug fixed above stayed invisible.
|
||||
//
|
||||
// `failed`, NOT `on`: `on` keeps the pane on a CLEAN exit too, so typing
|
||||
// `exit` in a remote shell leaves a dead pane behind, the session outlives it,
|
||||
// and the next launch's `-A` reattaches to that corpse ("Pane is dead (status
|
||||
// 0)") instead of starting a shell — verified against a real tmux. `failed`
|
||||
// keeps the pane only on a non-zero exit, which is exactly the diagnostic case.
|
||||
//
|
||||
// LAST in the chain on purpose: tmux aborts the remaining commands of a `\;`
|
||||
// sequence once one errors (also verified), and `failed` needs tmux >= 3.2 on
|
||||
// the REMOTE host. Trailing, a rejection costs only this option; leading, it
|
||||
// would silently drop status/mouse/prefix/escape-time/window-size with it.
|
||||
`set -t ${remoteName} remain-on-exit failed`,
|
||||
].join(' \\; ');
|
||||
|
||||
// ssh runs its trailing args through the remote login shell, so the entire
|
||||
@@ -917,6 +995,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'codex':
|
||||
return `${modeCommand} resume ${resumeId}`;
|
||||
case 'antigravity':
|
||||
return `${modeCommand} --conversation ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
}
|
||||
@@ -1485,8 +1565,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const exports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||||
...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
|
||||
mode === 'codex' || mode === 'gemini' || mode === 'antigravity'
|
||||
? 'export COLORTERM=truecolor'
|
||||
: 'unset COLORTERM',
|
||||
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' ? ['unset NO_COLOR'] : []),
|
||||
// Stamp each Codex pane with a unique originator so the response-viewer
|
||||
// can locate THIS pane's rollout exactly — codex writes the value into
|
||||
// session_meta.originator of every rollout it creates. Without it,
|
||||
@@ -1569,6 +1651,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dir = resolveGeminiDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'antigravity') {
|
||||
const dir = resolveAntigravityDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
@@ -1616,6 +1702,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1667,6 +1754,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'gemini' && !cliDir) {
|
||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||||
}
|
||||
if (mode === 'antigravity' && !cliDir) {
|
||||
throw new Error(
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
@@ -1679,6 +1771,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -1901,6 +1994,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1938,6 +2032,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
|
||||
+33
-16
@@ -40,6 +40,7 @@ interface TranscriptContentBlock {
|
||||
text?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
@@ -328,10 +329,7 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
this.handleResultEntry(entry);
|
||||
break;
|
||||
case 'user':
|
||||
// User message means new turn, reset some state
|
||||
this.state.isComplete = false;
|
||||
this.state.hasError = false;
|
||||
this.state.errorMessage = null;
|
||||
this.handleUserEntry(entry);
|
||||
break;
|
||||
case 'system':
|
||||
// System messages are informational
|
||||
@@ -360,23 +358,42 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
this.state.currentTool = block.name;
|
||||
this.emit('transcript:tool_start', block.name);
|
||||
} else if (block.type === 'tool_result') {
|
||||
// Tool completed
|
||||
const wasError = block.is_error === true;
|
||||
const toolName = this.state.currentTool;
|
||||
this.state.toolExecuting = false;
|
||||
this.state.currentTool = null;
|
||||
if (toolName) {
|
||||
this.emit('transcript:tool_end', toolName, wasError);
|
||||
}
|
||||
if (wasError && block.content) {
|
||||
this.state.hasError = true;
|
||||
this.state.errorMessage = String(block.content).slice(0, 200);
|
||||
}
|
||||
this.handleToolResult(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserEntry(entry: TranscriptEntry): void {
|
||||
// A user-authored prompt starts a turn, while Claude tool results also use
|
||||
// user entries. Reset turn state first, then close any completed tool.
|
||||
this.state.isComplete = false;
|
||||
this.state.hasError = false;
|
||||
this.state.errorMessage = null;
|
||||
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
this.handleToolResult(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolResult(block: TranscriptContentBlock): void {
|
||||
const wasError = block.is_error === true;
|
||||
const toolName = this.state.currentTool;
|
||||
this.state.toolExecuting = false;
|
||||
this.state.currentTool = null;
|
||||
if (toolName) {
|
||||
this.emit('transcript:tool_end', toolName, wasError);
|
||||
}
|
||||
if (wasError && block.content) {
|
||||
this.state.hasError = true;
|
||||
this.state.errorMessage = String(block.content).slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResultEntry(entry: TranscriptEntry): void {
|
||||
// Result entry indicates completion
|
||||
this.state.isComplete = true;
|
||||
|
||||
+5
-20
@@ -15,10 +15,8 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { resolveCloudflaredPath } from './utils/cloudflared-resolver.js';
|
||||
import {
|
||||
QR_TOKEN_TTL_MS,
|
||||
QR_TOKEN_GRACE_MS,
|
||||
@@ -95,23 +93,10 @@ export class TunnelManager extends EventEmitter {
|
||||
private resolveCloudflared(): string | null {
|
||||
if (this.cloudflaredPath) return this.cloudflaredPath;
|
||||
|
||||
// Check ~/.local/bin first (common user install location)
|
||||
const localBin = join(homedir(), '.local', 'bin', 'cloudflared');
|
||||
if (existsSync(localBin)) {
|
||||
this.cloudflaredPath = localBin;
|
||||
return localBin;
|
||||
}
|
||||
|
||||
// Check /usr/local/bin
|
||||
const usrLocalBin = '/usr/local/bin/cloudflared';
|
||||
if (existsSync(usrLocalBin)) {
|
||||
this.cloudflaredPath = usrLocalBin;
|
||||
return usrLocalBin;
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
this.cloudflaredPath = 'cloudflared';
|
||||
return 'cloudflared';
|
||||
// Shared with the welcome-screen availability check, so the button and the
|
||||
// spawn can never disagree about where cloudflared lives.
|
||||
this.cloudflaredPath = resolveCloudflaredPath() ?? 'cloudflared';
|
||||
return this.cloudflaredPath;
|
||||
}
|
||||
|
||||
/** Clear all pending timers */
|
||||
|
||||
@@ -97,6 +97,22 @@ export interface FilesystemBrowseData {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Response payload for `PUT /api/sessions/:id/file-content` (File Viewer edit mode). */
|
||||
export interface FileWriteData {
|
||||
/** Workspace-relative path as submitted */
|
||||
path: string;
|
||||
/** Size of the written content in bytes */
|
||||
size: number;
|
||||
/** mtime of the file after the write */
|
||||
mtimeMs: number;
|
||||
/** sha256 hex of the written bytes — the client's next baseHash */
|
||||
hash: string;
|
||||
/** Line count of the written content */
|
||||
totalLines: number;
|
||||
/** Line-ending style that was applied */
|
||||
eol: 'lf' | 'crlf';
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
+34
-4
@@ -8,12 +8,13 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' (which CLI backend)
|
||||
* - 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)
|
||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -42,9 +43,12 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity';
|
||||
|
||||
export type RemoteCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Advanced SSH connection options shared by RemoteHost and SessionRemote.
|
||||
@@ -150,7 +154,10 @@ export interface RemoteSessionInfo {
|
||||
// into the same long-lived container. See `docs/docker-cases-plan.md`.
|
||||
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
@@ -298,6 +305,8 @@ export interface CodexConfig {
|
||||
resumeSessionId?: string;
|
||||
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
||||
dangerouslyBypassApprovals?: boolean;
|
||||
/** Enable Codex's decorative TUI animations. Disable to reduce remote terminal redraws. */
|
||||
animations?: boolean;
|
||||
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
|
||||
renderMode?: CodexRenderMode;
|
||||
}
|
||||
@@ -312,6 +321,16 @@ export interface GeminiConfig {
|
||||
resumeSession?: string;
|
||||
}
|
||||
|
||||
/** Antigravity CLI (agy) session configuration */
|
||||
export interface AntigravityConfig {
|
||||
/** Model identifier. Passed via --model. */
|
||||
model?: string;
|
||||
/** Auto-approve all tool permission requests (passes --dangerously-skip-permissions). Absent = agy's default prompting. */
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/** Resume a previous conversation by ID (passed via --conversation). */
|
||||
resumeConversationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -453,12 +472,23 @@ export interface SessionState {
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini-specific configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Antigravity-specific configuration (only for mode === 'antigravity') */
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
* Wall-clock ms of this pane's last Enter (Session.lastSubmitAt). Persisted
|
||||
* because it is the response-viewer's only anchor for re-deriving the pane's
|
||||
* live conversation after a Codeman restart: `start()` resets
|
||||
* `claudeSessionId` to the launch id even when re-attaching to a mux session
|
||||
* whose CLI has since moved on via `/clear`, and the correlation cannot run
|
||||
* again until the pane's own Enter is known.
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
/**
|
||||
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Antigravity CLI (`agy`) binary across common install paths.
|
||||
*
|
||||
* Mirrors gemini-cli-resolver.ts. Google's installer (antigravity.google/cli/install.sh)
|
||||
* places the binary at ~/.local/bin/agy; the other locations cover manual installs.
|
||||
*
|
||||
* @module utils/antigravity-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Antigravity CLI binary may be installed */
|
||||
const ANTIGRAVITY_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
join(homedir(), '.antigravity', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the agy binary (empty string = searched but not found) */
|
||||
let _antigravityDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `agy` binary.
|
||||
* Checks `which agy` first, then falls back to common install locations.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveAntigravityDir(): string | null {
|
||||
if (_antigravityDir !== null) return _antigravityDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which agy', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_antigravityDir = dirname(result);
|
||||
return _antigravityDir;
|
||||
}
|
||||
} catch {
|
||||
// agy not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'agy'))) {
|
||||
_antigravityDir = dir;
|
||||
return _antigravityDir;
|
||||
}
|
||||
}
|
||||
|
||||
_antigravityDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Antigravity CLI is available on the system.
|
||||
*/
|
||||
export function isAntigravityAvailable(): boolean {
|
||||
return resolveAntigravityDir() !== null;
|
||||
}
|
||||
@@ -26,6 +26,15 @@ const CLAUDE_SEARCH_DIRS = [
|
||||
/** Cached directory containing the claude binary (empty string = searched but not found) */
|
||||
let _claudeDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Returns true if the Claude CLI binary can be located (via `which` or one of
|
||||
* the common install directories). Mirrors `isGeminiAvailable`/`isOpenCodeAvailable`/
|
||||
* `isCodexAvailable` in the sibling resolvers.
|
||||
*/
|
||||
export function isClaudeAvailable(): boolean {
|
||||
return findClaudeDir() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `claude` binary.
|
||||
* Checks `which claude` first, then falls back to common install locations.
|
||||
@@ -59,6 +68,21 @@ export function findClaudeDir(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute path to the `claude` binary, falling back to the bare
|
||||
* name `'claude'` when it cannot be located (so PATH resolution still gets a
|
||||
* chance).
|
||||
*
|
||||
* Preferred over passing `'claude'` to `pty.spawn()`: a PTY child resolves the
|
||||
* command against the environment it is handed, and an install that lives in
|
||||
* `~/.local/bin` or `~/.claude/local` is frequently absent from the PATH the
|
||||
* server process inherited (issue #6).
|
||||
*/
|
||||
export function getClaudeBinaryPath(): string {
|
||||
const dir = findClaudeDir();
|
||||
return dir ? join(dir, 'claude') : 'claude';
|
||||
}
|
||||
|
||||
/** Cached augmented PATH string */
|
||||
let _augmentedPath: string | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @fileoverview Resolve the `cloudflared` binary across common install paths.
|
||||
*
|
||||
* Mirrors the CLI resolvers (gemini-cli-resolver.ts et al), for the same reason
|
||||
* they exist: the welcome screen should not offer a button whose only possible
|
||||
* outcome is an error toast.
|
||||
*
|
||||
* The search list is deliberately the SAME one `TunnelManager.resolveCloudflared()`
|
||||
* has always used, and that method now delegates here so the two can never drift.
|
||||
* The difference is the fallback: this module answers "is it installed?" honestly
|
||||
* with null, while the tunnel manager keeps falling back to the bare name so a
|
||||
* cloudflared that only exists somewhere on the tunnel process's PATH still
|
||||
* starts. A stricter answer there would turn a working tunnel into a refusal.
|
||||
*
|
||||
* @module utils/cloudflared-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the cloudflared binary may be installed */
|
||||
const CLOUDFLARED_SEARCH_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin'];
|
||||
|
||||
/** Cached path to the cloudflared binary (empty string = searched but not found) */
|
||||
let _cloudflaredPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the `cloudflared` binary.
|
||||
*
|
||||
* @returns Absolute path, or null if not found
|
||||
*/
|
||||
export function resolveCloudflaredPath(): string | null {
|
||||
if (_cloudflaredPath !== null) return _cloudflaredPath || null;
|
||||
|
||||
for (const dir of CLOUDFLARED_SEARCH_DIRS) {
|
||||
const candidate = join(dir, 'cloudflared');
|
||||
if (existsSync(candidate)) {
|
||||
_cloudflaredPath = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync('which cloudflared', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_cloudflaredPath = result;
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not on PATH either.
|
||||
}
|
||||
|
||||
_cloudflaredPath = ''; // mark as searched, not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cloudflared is available on the system.
|
||||
*/
|
||||
export function isCloudflaredAvailable(): boolean {
|
||||
return resolveCloudflaredPath() !== null;
|
||||
}
|
||||
+4
-1
@@ -26,7 +26,10 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @fileoverview Runtime self-heal for node-pty's macOS `spawn-helper`.
|
||||
*
|
||||
* node-pty@1.1.0 ships its macOS prebuilt helper as
|
||||
* `prebuilds/darwin-<arch>/spawn-helper` with mode 0644 (no execute bit). On
|
||||
* macOS every PTY is launched through that helper via posix_spawnp, so a
|
||||
* non-executable helper turns every session start into
|
||||
* `Error: posix_spawnp failed.` (issues #6 and #204). The bug is macOS-only:
|
||||
* `spawn-helper` is an `OS=="mac"` gyp target and pty.cc only spawns it under
|
||||
* `#if defined(__APPLE__)`, and node-pty ships no Linux prebuild, so Linux always
|
||||
* compiles a correctly-permissioned helper from source.
|
||||
*
|
||||
* `scripts/fix-node-pty.mjs` fixes this at install time. This module is the
|
||||
* safety net for installs that are already broken: the first PTY spawn that
|
||||
* fails this way is repaired and retried in-process, so the user never sees a
|
||||
* dead session. If the retry still fails, the thrown error carries the manual
|
||||
* repair command instead of a bare "posix_spawnp failed".
|
||||
*
|
||||
* @module utils/node-pty-repair
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** The one-line fix appended to errors we could not repair automatically. */
|
||||
export const SPAWN_HELPER_FIX_HINT =
|
||||
'node-pty cannot execute its spawn-helper. Repair it with: npm run fix:node-pty ' +
|
||||
'(or: chmod +x node_modules/node-pty/prebuilds/*/spawn-helper)';
|
||||
|
||||
/** Set once a repair has been attempted, so a genuinely broken install cannot chmod-storm. */
|
||||
let repairAttempted = false;
|
||||
|
||||
/**
|
||||
* True when an error is node-pty failing to launch its spawn-helper.
|
||||
*
|
||||
* The native throw site is `throw Napi::Error::New(napiEnv, "posix_spawnp failed.")`
|
||||
* in pty.cc, reached only on Apple platforms.
|
||||
*/
|
||||
export function isSpawnHelperFailure(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err ?? '');
|
||||
return /posix_spawnp|spawn-helper/i.test(message);
|
||||
}
|
||||
|
||||
/** Locates the installed node-pty package root, or null when it can't be resolved. */
|
||||
export function findNodePtyDir(): string | null {
|
||||
// node-pty declares no "exports" map, so the package.json subpath resolves and
|
||||
// lands on the package root. require.resolve('node-pty') would return
|
||||
// <pkg>/lib/index.js, one level deeper than callers need.
|
||||
try {
|
||||
return dirname(require.resolve('node-pty/package.json'));
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
return join(dirname(require.resolve('node-pty')), '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every `spawn-helper` present in a node-pty install.
|
||||
*
|
||||
* node-pty's loader checks `build/Release`, `build/Debug`, then
|
||||
* `prebuilds/<platform>-<arch>`, and takes the helper from whichever directory
|
||||
* the native module loaded out of, so every copy has to be executable, not just
|
||||
* the one this machine happens to use.
|
||||
*/
|
||||
export function listSpawnHelpers(ptyDir: string): string[] {
|
||||
const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')];
|
||||
|
||||
const prebuilds = join(ptyDir, 'prebuilds');
|
||||
if (existsSync(prebuilds)) {
|
||||
try {
|
||||
for (const entry of readdirSync(prebuilds, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name));
|
||||
}
|
||||
} catch {
|
||||
/* unreadable prebuilds dir: nothing to repair there */
|
||||
}
|
||||
}
|
||||
|
||||
return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the execute bit to every `spawn-helper` missing it.
|
||||
*
|
||||
* @param ptyDir - node-pty package root; resolved automatically when omitted.
|
||||
* @returns Paths actually changed (empty when nothing needed repair, or node-pty
|
||||
* is missing, or the files are not writable).
|
||||
*/
|
||||
export function repairSpawnHelperPermissions(ptyDir?: string): string[] {
|
||||
const dir = ptyDir ?? findNodePtyDir();
|
||||
if (!dir) return [];
|
||||
|
||||
const repaired: string[] = [];
|
||||
for (const helper of listSpawnHelpers(dir)) {
|
||||
try {
|
||||
const mode = statSync(helper).mode & 0o777;
|
||||
if ((mode & 0o111) === 0o111) continue;
|
||||
chmodSync(helper, mode | 0o755);
|
||||
repaired.push(helper);
|
||||
} catch {
|
||||
// Read-only install (or not ours to chmod): fall through to the hint.
|
||||
}
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/** Wraps an error so the message carries the actionable repair command. */
|
||||
function withFixHint(err: unknown): Error {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Error(`${message}. ${SPAWN_HELPER_FIX_HINT}`, { cause: err });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a `pty.spawn()` call, repairing a non-executable spawn-helper and
|
||||
* retrying once if that is why it failed.
|
||||
*
|
||||
* Any unrelated spawn error is rethrown untouched, so this stays invisible on
|
||||
* every platform but a broken macOS install.
|
||||
*
|
||||
* @param spawn - The `pty.spawn(...)` call to run.
|
||||
* @param ptyDir - node-pty package root; resolved automatically when omitted.
|
||||
*/
|
||||
export function spawnPtyWithHelperRepair<T>(spawn: () => T, ptyDir?: string): T {
|
||||
try {
|
||||
return spawn();
|
||||
} catch (err) {
|
||||
if (!isSpawnHelperFailure(err)) throw err;
|
||||
if (repairAttempted) throw withFixHint(err);
|
||||
|
||||
repairAttempted = true;
|
||||
const repaired = repairSpawnHelperPermissions(ptyDir);
|
||||
if (repaired.length === 0) throw withFixHint(err);
|
||||
|
||||
console.warn(`[node-pty] spawn-helper was not executable, repaired ${repaired.join(', ')} and retrying`);
|
||||
try {
|
||||
return spawn();
|
||||
} catch (retryErr) {
|
||||
throw withFixHint(retryErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget that a repair was already attempted in this process. */
|
||||
export function resetSpawnHelperRepairState(): void {
|
||||
repairAttempted = false;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @fileoverview Resolve a real, launchable login shell for `mode: 'shell'` sessions.
|
||||
*
|
||||
* The tmux pane command for a local shell session used to be the literal string
|
||||
* `$SHELL`. That string is embedded in the `bash -c "…"` argument of the
|
||||
* `respawn-pane` line, which `execSync` hands to `/bin/sh -c` — so `$SHELL` was
|
||||
* expanded by the SERVER process's shell (not the pane's), against the SERVER
|
||||
* process's env. Containers and system-level systemd units do not set `SHELL`,
|
||||
* so the expansion produced an empty string and the pane command ended in a
|
||||
* dangling `&&`:
|
||||
*
|
||||
* bash -c "cd \"/case\" && ulimit … && export … && "
|
||||
* -> bash: -c: line 1: syntax error: unexpected end of file
|
||||
*
|
||||
* The pane then died instantly (status 2) while tmux creation itself reported
|
||||
* success, which is exactly what issue #208 saw. Resolving the shell HERE, in
|
||||
* Node, removes the shell-expansion layer entirely and guarantees a non-empty
|
||||
* absolute path.
|
||||
*
|
||||
* @module utils/shell-resolver
|
||||
*/
|
||||
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { userInfo } from 'node:os';
|
||||
|
||||
/** Last-resort shells, in preference order. `/bin/sh` exists on every POSIX host. */
|
||||
const FALLBACK_SHELLS = ['/bin/bash', '/bin/zsh', '/bin/sh'];
|
||||
|
||||
/**
|
||||
* Shells that exist and are executable but immediately exit — a service account's
|
||||
* passwd entry commonly points at one, which would look identical to the crash
|
||||
* this module exists to prevent.
|
||||
*/
|
||||
const NON_INTERACTIVE_SHELLS = new Set(['nologin', 'false', 'true', 'sync']);
|
||||
|
||||
/**
|
||||
* Shells verified to accept BOTH `-i` and `-l`. Deliberately an allowlist, not a
|
||||
* blocklist: a shell that rejects an unknown flag exits immediately, which is the
|
||||
* dead-pane-on-arrival failure this module exists to prevent (#208). The passwd
|
||||
* entry is user data and can name anything — nushell, elvish, and xonsh all take
|
||||
* neither flag in this form, so they get a bare launch instead of a dead tab.
|
||||
*
|
||||
* csh/tcsh are excluded on purpose: tcsh honors `-l` only when it is the ONLY
|
||||
* flag, so `-i -l` would silently not be a login shell there anyway.
|
||||
*/
|
||||
const LOGIN_FLAG_SHELLS = new Set(['sh', 'bash', 'dash', 'ash', 'zsh', 'ksh', 'ksh93', 'mksh', 'pdksh', 'fish']);
|
||||
|
||||
function isUsableShell(candidate: string): boolean {
|
||||
if (!candidate.startsWith('/')) return false;
|
||||
const base = candidate.slice(candidate.lastIndexOf('/') + 1);
|
||||
if (NON_INTERACTIVE_SHELLS.has(base)) return false;
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an absolute path to an interactive shell, preferring the user's own.
|
||||
*
|
||||
* Order: `$SHELL` -> the passwd entry -> `/bin/bash` -> `/bin/zsh` -> `/bin/sh`.
|
||||
* Every candidate must be an absolute path to an executable that is not a
|
||||
* nologin-style stub. Always returns a non-empty string.
|
||||
*/
|
||||
export function resolveLocalShell(): string {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const envShell = process.env.SHELL?.trim();
|
||||
if (envShell) candidates.push(envShell);
|
||||
|
||||
try {
|
||||
// Throws when the uid has no /etc/passwd entry (common for `--user` containers).
|
||||
const passwdShell = userInfo().shell?.trim();
|
||||
if (passwdShell) candidates.push(passwdShell);
|
||||
} catch {
|
||||
/* no passwd entry — fall through to the static fallbacks */
|
||||
}
|
||||
|
||||
candidates.push(...FALLBACK_SHELLS);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isUsableShell(candidate)) return candidate;
|
||||
}
|
||||
|
||||
// Nothing was verifiable (exotic/read-restricted image). /bin/sh is still the
|
||||
// best guess and is far better than emitting an empty command.
|
||||
return '/bin/sh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags that make `shellPath` a login shell, or `''` when it takes none we trust.
|
||||
*
|
||||
* A tmux pane already hands the shell a tty, so it is interactive with or without
|
||||
* `-i` (verified: `$-` contains `i` for a bare `/bin/bash` in a pane, which is why
|
||||
* `~/.bashrc` has always been sourced). The flag that actually changes anything is
|
||||
* `-l`: it makes the pane a LOGIN shell, matching what tmux itself does when it
|
||||
* spawns a pane with no `default-command`, and picking up the `/etc/profile` and
|
||||
* `/etc/profile.d/*` PATH entries that a systemd-spawned server never sourced.
|
||||
*
|
||||
* `-i` is kept alongside it because for bash the two select different files —
|
||||
* login reads `~/.bash_profile`, interactive-non-login reads `~/.bashrc` — and
|
||||
* asking for both is the closest thing to "the shell the user actually gets".
|
||||
*
|
||||
* Returns a string ready to append to an already-escaped shell path.
|
||||
*/
|
||||
export function loginShellArgs(shellPath: string): string {
|
||||
const base = shellPath.slice(shellPath.lastIndexOf('/') + 1);
|
||||
return LOGIN_FLAG_SHELLS.has(base) ? ' -i -l' : '';
|
||||
}
|
||||
+52
-3
@@ -349,6 +349,22 @@ const DEFAULT_SHORTCUTS = [
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
|
||||
action: 'clearTerminal',
|
||||
},
|
||||
{
|
||||
id: 'copy-selection',
|
||||
group: 'Terminal',
|
||||
label: 'Copy Selection',
|
||||
// Bindings match on `key`, not `code`: xterm decides which byte to emit from the
|
||||
// PRODUCED character, so intercepting a physical KeyC that doesn't produce "c"
|
||||
// would diverge from the chord that actually sends ^C.
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: 'c' },
|
||||
{ modifiers: ['ctrl', 'shift'], key: 'C' },
|
||||
],
|
||||
// Dispatched by shouldCopyTerminalSelectionFromShortcut() in terminal-ui.js and
|
||||
// deliberately absent from SHORTCUT_ACTIONS: the generic capture loop always
|
||||
// preventDefaults on a match, which would cost the user the interrupt key.
|
||||
action: 'copyTerminalSelection',
|
||||
},
|
||||
{
|
||||
id: 'increase-font',
|
||||
group: 'Terminal',
|
||||
@@ -800,6 +816,10 @@ class CodemanApp {
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// Must run before the first session:created can arrive: markSessionTabEntering()
|
||||
// ignores ids until this sets up its state, which is what keeps the tabs
|
||||
// restored on page load from animating.
|
||||
this.initEntranceAnimations?.();
|
||||
// Remove mobile-init class now that JS has applied visibility settings.
|
||||
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
|
||||
document.documentElement.classList.remove('mobile-init');
|
||||
@@ -1563,6 +1583,12 @@ class CodemanApp {
|
||||
this.sessionOrder.push(data.id);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
// Idempotent per id: the POST response and the session:created event both
|
||||
// land here, and a batch launched together cascades in creation order.
|
||||
this.markSessionTabEntering?.(data.id);
|
||||
// The pane is one shared element, so it is only marked here and played when
|
||||
// this session is actually selected (see selectSession).
|
||||
this.markTerminalEntering?.(data.id);
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
@@ -1974,7 +2000,7 @@ class CodemanApp {
|
||||
// Render conversation thread
|
||||
const mode = this.sessions.get(this.activeSessionId)?.mode;
|
||||
const agentLabel =
|
||||
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'antigravity' ? 'Antigravity' : mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
@@ -3280,6 +3306,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_renderSessionTabsImmediate() {
|
||||
// Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental
|
||||
// branch below rewrites .tab-name's innerHTML, which destroys the inline rename
|
||||
// <input> mid-keystroke. Guarding only the scheduler is not enough: a render
|
||||
// debounced just BEFORE the rename opened still fires ~100ms later and lands
|
||||
// here directly. finishRename() re-renders on both commit and cancel, so a
|
||||
// render dropped here is picked back up when the rename settles.
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
@@ -3443,6 +3476,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this.updateTabOverflowMode();
|
||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||
this._applyTabEntrances?.();
|
||||
// Phone overview rides on this one call: every state change it cares about
|
||||
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
|
||||
// already funnels through here. No-ops unless that surface is showing.
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
@@ -3532,7 +3572,7 @@ class CodemanApp {
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : ''}
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
@@ -3569,6 +3609,9 @@ class CodemanApp {
|
||||
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
|
||||
// tabs-auto-wrap class until the next content render.
|
||||
this.updateTabOverflowMode();
|
||||
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
|
||||
// than restarting, since this rebuild just destroyed the animating elements.
|
||||
this._applyTabEntrances?.();
|
||||
}
|
||||
|
||||
// Set up arrow key navigation for session tabs (accessibility)
|
||||
@@ -4100,6 +4143,10 @@ class CodemanApp {
|
||||
// selectSession or reconnect catches up.
|
||||
this._updateSseSubscription(sessionId);
|
||||
this.hideWelcome();
|
||||
// Terminal-pane entrance: plays for a freshly created session, and on every
|
||||
// switch when that option is on. Transform/opacity/clip-path only, xterm's
|
||||
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
|
||||
this.playTerminalEntrance?.(sessionId);
|
||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
@@ -4610,7 +4657,9 @@ class CodemanApp {
|
||||
? 'Kill Tmux & Codex'
|
||||
: session.mode === 'gemini'
|
||||
? 'Kill Tmux & Gemini'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'antigravity'
|
||||
? 'Kill Tmux & Antigravity'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* @fileoverview Entrance animations for the four things that appear when work
|
||||
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
|
||||
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
|
||||
* One picker per surface, plus themes that set all four to a matching look.
|
||||
*
|
||||
* Everything is OFF by default (the `legacy` theme), so an untouched install
|
||||
* behaves exactly as it did before this module existed. Opt in via App Settings
|
||||
* → Appearance → Entrance Animations.
|
||||
*
|
||||
* Four constraints shape the design:
|
||||
*
|
||||
* 1. `_fullRenderSessionTabs()` replaces the tab strip's entire innerHTML, and
|
||||
* `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''` and rebuilds
|
||||
* every path. Both run constantly while sessions and agents are spawning, so
|
||||
* an animating tab or line element is DESTROYED mid-flight. Those two are
|
||||
* therefore tracked by id in `_tabEnterActive` / `_lineEnterActive` and
|
||||
* re-applied to the fresh element with a NEGATIVE animation-delay, resuming at
|
||||
* the same offset instead of restarting or snapping to the end. Windows are
|
||||
* stable DOM and need none of this.
|
||||
* 2. Connection-line geometry comes from `getBoundingClientRect()` on the window.
|
||||
* A window entrance that starts with a transform would move that rect, so the
|
||||
* `beam` style (which must hold still while its line draws toward it) animates
|
||||
* opacity and filter only. Every other style refreshes the lines when it ends.
|
||||
* 3. The terminal pane is ONE shared element, so its entrance is marked at
|
||||
* session creation but played at selection: a session created in the
|
||||
* background must not animate the pane the user is currently looking at. Its
|
||||
* styles are also restricted to transform/opacity/clip-path (see below).
|
||||
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
|
||||
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
|
||||
* once-per-id even though the POST response and the SSE event both call
|
||||
* `_onSessionCreated`.
|
||||
*
|
||||
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
|
||||
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
|
||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
||||
* so styles can be compared without spawning real sessions or agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
||||
*/
|
||||
|
||||
/** Tab entrance styles. `key` doubles as the `data-tab-anim` value. */
|
||||
const TAB_ANIM_STYLES = [
|
||||
{ key: 'slide', label: 'Slide', blurb: 'Drifts in from the right.', duration: 380 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs past full size, then settles.', duration: 460 },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Snaps open as a hot line, then unfolds.', duration: 520 },
|
||||
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
|
||||
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Window entrance styles. `fly` is the pre-existing behaviour (the window flies
|
||||
* out of its parent tab via a JS transition in subagent-windows.js); every other
|
||||
* style positions the window at its resting spot and runs a CSS animation there.
|
||||
*/
|
||||
const WIN_ANIM_STYLES = [
|
||||
{ key: 'fly', label: 'Fly from tab', blurb: 'Current behaviour: flies out of the tab, scaling up.', duration: 400 },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Bursts open as a hot line, then unfolds vertically.', duration: 560 },
|
||||
{ key: 'materialize', label: 'Materialize', blurb: 'Resolves out of a blur with a short glitch.', duration: 620 },
|
||||
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
|
||||
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/** Connection-line entrance styles. `key` doubles as the `data-line-anim` value. */
|
||||
const LINE_ANIM_STYLES = [
|
||||
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
|
||||
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Main-terminal entrance styles: the pane a session's CLI actually runs in.
|
||||
*
|
||||
* ⚠ These may only animate transform, opacity and clip-path. xterm's FitAddon
|
||||
* derives rows/cols from `getComputedStyle(parent).width/height`, which reports
|
||||
* the untransformed layout box, so transforms are safe, but animating width,
|
||||
* height or padding would feed wrong dimensions into `resize()` and through to
|
||||
* the PTY. Colour washes go on `.terminal-container::before`, never a `filter`
|
||||
* on the container: that would blur a full-screen WebGL canvas every frame.
|
||||
*/
|
||||
const TERM_ANIM_STYLES = [
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||
const BEAM_HOLD_MS = 360;
|
||||
|
||||
/** One-click combinations that read as a single look. */
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
|
||||
* `fly` behaviour Codeman already had before this module existed. So a user who
|
||||
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
|
||||
* Entrance Animations, which persists to the localStorage keys below.
|
||||
*/
|
||||
const TAB_ANIM_DEFAULT = 'off';
|
||||
const WIN_ANIM_DEFAULT = 'fly';
|
||||
const LINE_ANIM_DEFAULT = 'off';
|
||||
const TERM_ANIM_DEFAULT = 'off';
|
||||
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||
|
||||
const ANIM_KEYS = {
|
||||
tab: 'codeman:tabAnim',
|
||||
win: 'codeman:winAnim',
|
||||
line: 'codeman:lineAnim',
|
||||
term: 'codeman:termAnim',
|
||||
termSwitch: 'codeman:termAnimOnSwitch',
|
||||
stagger: 'codeman:tabAnimStagger',
|
||||
speed: 'codeman:tabAnimSpeed',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve every style/timing and stamp them on <html>. Called once at startup. */
|
||||
initEntranceAnimations() {
|
||||
this._tabEnterSeen = new Set();
|
||||
this._tabEnterActive = new Map();
|
||||
this._lineEnterActive = new Map();
|
||||
this._tabEnterBatchIndex = 0;
|
||||
this._tabEnterLastMarkTs = 0;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
// ?tabanim= / ?winanim= / ?lineanim= win for a single load, so a style can be
|
||||
// tried without persisting over whatever is saved.
|
||||
const pick = (param, styles, storeKey, fallback) => {
|
||||
const fromUrl = params.get(param);
|
||||
if (styles.some((s) => s.key === fromUrl)) return fromUrl;
|
||||
return this._animRead(storeKey, fallback);
|
||||
};
|
||||
|
||||
this.setTabAnimStyle(pick('tabanim', TAB_ANIM_STYLES, ANIM_KEYS.tab, TAB_ANIM_DEFAULT), { persist: false });
|
||||
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
|
||||
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||
|
||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
|
||||
this.setAnimSpeed(Number(this._animRead(ANIM_KEYS.speed, 1)), { persist: false });
|
||||
|
||||
if (params.get('animlab') === '1' || params.get('tabanimlab') === '1') this.openAnimLab();
|
||||
},
|
||||
|
||||
_animRead(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null || raw === '' ? fallback : raw;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
|
||||
_animWrite(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
/* private mode / quota, the in-memory value still applies for this load */
|
||||
}
|
||||
},
|
||||
|
||||
_setAnimStyle(prop, key, styles, fallback, attr, storeKey, persist) {
|
||||
const style = styles.some((s) => s.key === key) ? key : fallback;
|
||||
this[prop] = style;
|
||||
document.documentElement.setAttribute(attr, style);
|
||||
if (persist) this._animWrite(storeKey, style);
|
||||
// Keep the lab's highlight honest when a style is set from anywhere other
|
||||
// than the lab's own buttons (a theme, a URL param, the console).
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
setTabAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_tabAnimStyle', key, TAB_ANIM_STYLES, TAB_ANIM_DEFAULT, 'data-tab-anim', ANIM_KEYS.tab, persist);
|
||||
},
|
||||
|
||||
setWinAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_winAnimStyle', key, WIN_ANIM_STYLES, WIN_ANIM_DEFAULT, 'data-win-anim', ANIM_KEYS.win, persist);
|
||||
},
|
||||
|
||||
setLineAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_lineAnimStyle', key, LINE_ANIM_STYLES, LINE_ANIM_DEFAULT, 'data-line-anim', ANIM_KEYS.line, persist);
|
||||
},
|
||||
|
||||
setTermAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
|
||||
},
|
||||
|
||||
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||
this._termAnimOnSwitch = !!on;
|
||||
if (persist) this._animWrite(ANIM_KEYS.termSwitch, on ? '1' : '0');
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
/** Apply a theme: one look across tabs, windows, lines and the terminal. */
|
||||
setAnimTheme(themeKey) {
|
||||
const theme = ANIM_THEMES.find((t) => t.key === themeKey);
|
||||
if (!theme) return;
|
||||
this.setTabAnimStyle(theme.tab);
|
||||
this.setWinAnimStyle(theme.win);
|
||||
this.setLineAnimStyle(theme.line);
|
||||
this.setTermAnimStyle(theme.term);
|
||||
this._syncEntranceAnimSetting?.();
|
||||
},
|
||||
|
||||
/** The theme matching the four current styles, or 'custom' for a lab mix. */
|
||||
currentAnimTheme() {
|
||||
const match = ANIM_THEMES.find(
|
||||
(t) =>
|
||||
t.tab === this._tabAnimStyle &&
|
||||
t.win === this._winAnimStyle &&
|
||||
t.line === this._lineAnimStyle &&
|
||||
t.term === this._termAnimStyle
|
||||
);
|
||||
return match ? match.key : 'custom';
|
||||
},
|
||||
|
||||
// ── App Settings picker ───────────────────────────────────────────────────
|
||||
//
|
||||
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
||||
// styles live in their own localStorage keys, so they stay per-device and never
|
||||
// reach `PUT /api/settings`, whose schema is .strict() and would reject them.
|
||||
|
||||
_syncEntranceAnimSetting() {
|
||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||
if (!sel) return;
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setTabAnimStagger(ms, { persist = true } = {}) {
|
||||
const value = Number.isFinite(ms) ? Math.max(0, Math.min(400, Math.round(ms))) : TAB_ANIM_STAGGER_DEFAULT;
|
||||
this._tabAnimStagger = value;
|
||||
if (persist) this._animWrite(ANIM_KEYS.stagger, value);
|
||||
},
|
||||
|
||||
setAnimSpeed(multiplier, { persist = true } = {}) {
|
||||
const value = Number.isFinite(multiplier) ? Math.max(0.25, Math.min(3, multiplier)) : 1;
|
||||
this._animSpeed = value;
|
||||
// Every keyframe block reads this, so one variable retimes all of them.
|
||||
document.documentElement.style.setProperty('--anim-enter-scale', String(1 / value));
|
||||
if (persist) this._animWrite(ANIM_KEYS.speed, value);
|
||||
},
|
||||
|
||||
_styleDuration(styles, key) {
|
||||
return (styles.find((s) => s.key === key)?.duration || 0) / (this._animSpeed || 1);
|
||||
},
|
||||
|
||||
_tabAnimDuration() {
|
||||
return this._styleDuration(TAB_ANIM_STYLES, this._tabAnimStyle);
|
||||
},
|
||||
|
||||
_winAnimDuration() {
|
||||
return this._styleDuration(WIN_ANIM_STYLES, this._winAnimStyle);
|
||||
},
|
||||
|
||||
_lineAnimDuration() {
|
||||
return this._styleDuration(LINE_ANIM_STYLES, this._lineAnimStyle);
|
||||
},
|
||||
|
||||
_termAnimDuration() {
|
||||
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||
},
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
||||
markSessionTabEntering(id) {
|
||||
if (!id || this._tabAnimStyle === 'off') return;
|
||||
if (!this._tabEnterSeen) return; // initEntranceAnimations() has not run yet
|
||||
if (this._tabEnterSeen.has(id)) return;
|
||||
this._tabEnterSeen.add(id);
|
||||
|
||||
const now = performance.now();
|
||||
// A launch landing well after the previous one starts its own cascade rather
|
||||
// than inheriting a large stale offset.
|
||||
if (now - this._tabEnterLastMarkTs > TAB_ANIM_BATCH_WINDOW_MS) this._tabEnterBatchIndex = 0;
|
||||
this._tabEnterLastMarkTs = now;
|
||||
|
||||
this._tabEnterActive.set(id, {
|
||||
startTs: now,
|
||||
staggerMs: this._tabEnterBatchIndex * this._tabAnimStagger,
|
||||
});
|
||||
this._tabEnterBatchIndex += 1;
|
||||
},
|
||||
|
||||
/** Attach (or resume) the entrance animation on freshly rendered tabs. */
|
||||
_applyTabEntrances() {
|
||||
const active = this._tabEnterActive;
|
||||
if (!active || active.size === 0) return;
|
||||
if (this._tabAnimStyle === 'off') {
|
||||
active.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
const now = performance.now();
|
||||
const duration = this._tabAnimDuration();
|
||||
|
||||
for (const [id, state] of active) {
|
||||
const elapsed = now - state.startTs;
|
||||
// Ran to completion while the element was detached, nothing left to show.
|
||||
if (elapsed > state.staggerMs + duration + 50) {
|
||||
active.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tab = container.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (!tab) continue; // not rendered yet; a later render picks it up
|
||||
// Already running on this element. Re-stamping the delay would jump it, and
|
||||
// the incremental render path can reach here for the same element.
|
||||
if (tab.classList.contains('tab-enter')) continue;
|
||||
|
||||
// Negative delay resumes mid-animation, so an unrelated re-render mid-cascade
|
||||
// does not restart the tab or make it snap.
|
||||
tab.style.setProperty('--tab-enter-delay', `${state.staggerMs - elapsed}ms`);
|
||||
tab.classList.add('tab-enter');
|
||||
const done = () => {
|
||||
tab.classList.remove('tab-enter');
|
||||
tab.style.removeProperty('--tab-enter-delay');
|
||||
this._tabEnterActive?.delete(id);
|
||||
};
|
||||
tab.addEventListener('animationend', done, { once: true });
|
||||
tab.addEventListener('animationcancel', done, { once: true });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Main terminal ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Queue the terminal pane to animate the first time this session is shown.
|
||||
* Marked at creation but PLAYED at selection, because the pane is one shared
|
||||
* element: a session created in the background must not animate the pane the
|
||||
* user is currently looking at.
|
||||
*/
|
||||
markTerminalEntering(id) {
|
||||
if (!id || this._termAnimStyle === 'off') return;
|
||||
if (!this._termEnterPending) this._termEnterPending = new Set();
|
||||
this._termEnterPending.add(id);
|
||||
},
|
||||
|
||||
/** Play the terminal entrance for `sessionId`, if it is owed one. */
|
||||
playTerminalEntrance(sessionId) {
|
||||
const style = this._termAnimStyle || TERM_ANIM_DEFAULT;
|
||||
if (style === 'off') return;
|
||||
|
||||
const owed = sessionId && this._termEnterPending?.delete(sessionId);
|
||||
if (!owed && !this._termAnimOnSwitch) return;
|
||||
|
||||
const el = this.$('terminalContainer');
|
||||
if (!el) return;
|
||||
|
||||
// Restart cleanly when switching tabs faster than the animation runs.
|
||||
el.classList.remove('term-enter');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('term-enter');
|
||||
|
||||
clearTimeout(this._termEnterTimer);
|
||||
const done = () => {
|
||||
el.classList.remove('term-enter');
|
||||
clearTimeout(this._termEnterTimer);
|
||||
};
|
||||
el.addEventListener('animationend', done, { once: true });
|
||||
el.addEventListener('animationcancel', done, { once: true });
|
||||
// Backstop: a backgrounded tab never fires animationend, which would leave
|
||||
// the pane stuck at its 0% keyframe (invisible) when you come back to it.
|
||||
this._termEnterTimer = setTimeout(done, this._termAnimDuration() + 900);
|
||||
},
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when the window should be parked on its parent tab and flown to its
|
||||
* resting spot by subagent-windows.js. False for every CSS-animated style,
|
||||
* which needs the window to start at its final position.
|
||||
*/
|
||||
windowEntranceFliesFromTab() {
|
||||
return (this._winAnimStyle || WIN_ANIM_DEFAULT) === 'fly';
|
||||
},
|
||||
|
||||
/**
|
||||
* Run the window entrance on an already-positioned window.
|
||||
* @param {HTMLElement} win a .subagent-window or .ultracode-window
|
||||
*/
|
||||
applyWindowEntrance(win) {
|
||||
if (!win) return;
|
||||
const style = this._winAnimStyle || WIN_ANIM_DEFAULT;
|
||||
if (style === 'off' || style === 'fly') return;
|
||||
|
||||
// `beam` holds the window still (opacity/filter only) while its line draws
|
||||
// toward it, then materializes. Everything else starts immediately.
|
||||
if (style === 'beam') win.style.setProperty('--win-enter-delay', `${BEAM_HOLD_MS / (this._animSpeed || 1)}ms`);
|
||||
|
||||
win.classList.add('win-enter');
|
||||
const done = () => {
|
||||
win.classList.remove('win-enter');
|
||||
win.style.removeProperty('--win-enter-delay');
|
||||
// A transformed window reports a transformed rect, so lines drawn while it
|
||||
// was animating are slightly off. Redraw once it has settled.
|
||||
this.updateConnectionLines?.();
|
||||
};
|
||||
win.addEventListener('animationend', done, { once: true });
|
||||
win.addEventListener('animationcancel', done, { once: true });
|
||||
},
|
||||
|
||||
// ── Connection lines ──────────────────────────────────────────────────────
|
||||
|
||||
/** Queue an agent's connection line to draw itself on the next line rebuild. */
|
||||
markConnectionLineEntering(agentId) {
|
||||
if (!agentId || this._lineAnimStyle === 'off') return;
|
||||
if (!this._lineEnterActive) return;
|
||||
if (this._lineEnterActive.has(agentId)) return;
|
||||
this._lineEnterActive.set(agentId, { startTs: performance.now() });
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach (or resume) the draw-in animation on freshly rebuilt paths. Called at
|
||||
* the end of `_updateConnectionLinesImmediate()`, which has just thrown away
|
||||
* and recreated every path element.
|
||||
*/
|
||||
_applyLineEntrances(svg) {
|
||||
const active = this._lineEnterActive;
|
||||
if (!active || active.size === 0 || !svg) return;
|
||||
const style = this._lineAnimStyle || LINE_ANIM_DEFAULT;
|
||||
if (style === 'off') {
|
||||
active.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const duration = this._lineAnimDuration();
|
||||
|
||||
for (const [agentId, state] of active) {
|
||||
const elapsed = now - state.startTs;
|
||||
if (elapsed > duration + 50) {
|
||||
active.delete(agentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = svg.querySelector(`path[data-agent-id="${CSS.escape(agentId)}"]`);
|
||||
if (!path) continue;
|
||||
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
|
||||
path.classList.add('line-enter');
|
||||
|
||||
// `packet` rides a bright dash ON TOP of the normal dashed line, so the base
|
||||
// line keeps its look instead of being taken over by the animation.
|
||||
if (style === 'packet') {
|
||||
const packet = path.cloneNode(false);
|
||||
packet.removeAttribute('data-agent-id');
|
||||
packet.setAttribute('class', 'connection-line-packet');
|
||||
packet.style.setProperty('--line-len', `${len}px`);
|
||||
packet.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
|
||||
svg.appendChild(packet);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lab (compare styles without spawning sessions or agents) ───────────────
|
||||
|
||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||
openAnimLab() {
|
||||
if (document.getElementById('animLab')) return;
|
||||
|
||||
const group = (title, styles, attr) => `
|
||||
<div class="anim-lab-group">
|
||||
<div class="anim-lab-group-title">${title}</div>
|
||||
${styles
|
||||
.map(
|
||||
(s) => `<button type="button" class="anim-lab-style" data-attr="${attr}" data-style="${s.key}">
|
||||
<strong>${escapeHtml(s.label)}</strong><em>${escapeHtml(s.blurb)}</em>
|
||||
</button>`
|
||||
)
|
||||
.join('')}
|
||||
</div>`;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'animLab';
|
||||
panel.className = 'anim-lab';
|
||||
panel.innerHTML = `
|
||||
<div class="anim-lab-head">
|
||||
<span>Entrance lab</span>
|
||||
<button type="button" class="anim-lab-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="anim-lab-themes">
|
||||
${ANIM_THEMES.map((t) => `<button type="button" data-theme="${t.key}">${escapeHtml(t.label)}</button>`).join('')}
|
||||
</div>
|
||||
<div class="anim-lab-scroll">
|
||||
${group('Tabs', TAB_ANIM_STYLES, 'tab')}
|
||||
${group('Terminal pane', TERM_ANIM_STYLES, 'term')}
|
||||
<label class="anim-lab-check">
|
||||
<input type="checkbox" data-check="termSwitch"> Also on every tab switch
|
||||
</label>
|
||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
||||
</div>
|
||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
||||
</label>
|
||||
<label class="anim-lab-range">Speed <output data-out="speed"></output>
|
||||
<input type="range" data-range="speed" min="0.5" max="2" step="0.1">
|
||||
</label>
|
||||
<div class="anim-lab-demo">
|
||||
<span>Replay</span>
|
||||
<button type="button" data-demo="tabs">Tabs</button>
|
||||
<button type="button" data-demo="term">Pane</button>
|
||||
<button type="button" data-demo="window">Window</button>
|
||||
<button type="button" data-demo="all">All</button>
|
||||
</div>
|
||||
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
panel.querySelector('.anim-lab-close').addEventListener('click', () => this.closeAnimLab());
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.setAnimTheme(btn.dataset.theme);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance('all');
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const { attr, style } = btn.dataset;
|
||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||
else this.setLineAnimStyle(style);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
|
||||
});
|
||||
});
|
||||
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
||||
this.setTermAnimOnSwitch(e.target.checked);
|
||||
});
|
||||
panel.querySelectorAll('input[data-range]').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
if (input.dataset.range === 'stagger') this.setTabAnimStagger(Number(input.value));
|
||||
else this.setAnimSpeed(Number(input.value));
|
||||
this._syncAnimLab();
|
||||
});
|
||||
input.addEventListener('change', () => this.demoEntrance('all'));
|
||||
});
|
||||
panel.querySelectorAll('button[data-demo]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => this.demoEntrance(btn.dataset.demo));
|
||||
});
|
||||
|
||||
this._syncAnimLab();
|
||||
},
|
||||
|
||||
closeAnimLab() {
|
||||
document.getElementById('animLab')?.remove();
|
||||
this._clearEntranceDemo();
|
||||
},
|
||||
|
||||
_syncAnimLab() {
|
||||
const panel = document.getElementById('animLab');
|
||||
if (!panel) return;
|
||||
const current = {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
};
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||
});
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
||||
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
|
||||
});
|
||||
const check = panel.querySelector('input[data-check="termSwitch"]');
|
||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||
panel.querySelector('output[data-out="stagger"]').textContent = `${this._tabAnimStagger}ms`;
|
||||
panel.querySelector('input[data-range="speed"]').value = String(this._animSpeed);
|
||||
panel.querySelector('output[data-out="speed"]').textContent = `${this._animSpeed.toFixed(1)}x`;
|
||||
},
|
||||
|
||||
_clearEntranceDemo() {
|
||||
clearTimeout(this._animDemoTimer);
|
||||
clearTimeout(this._animDemoWindowTimer);
|
||||
document.querySelectorAll('.session-tab[data-demo]').forEach((el) => el.remove());
|
||||
document.querySelectorAll('.subagent-window[data-demo]').forEach((el) => el.remove());
|
||||
document.getElementById('animLabLines')?.remove();
|
||||
},
|
||||
|
||||
/**
|
||||
* The demo line gets its OWN svg overlay rather than sharing #connectionLines.
|
||||
* That overlay is rebuilt from real windows via `svg.innerHTML = ''`, so a fake
|
||||
* path dropped into it is erased the moment anything triggers a redraw -
|
||||
* including the demo window's own entrance finishing.
|
||||
*/
|
||||
_demoLineSvg() {
|
||||
let svg = document.getElementById('animLabLines');
|
||||
if (!svg) {
|
||||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.id = 'animLabLines';
|
||||
svg.setAttribute('class', 'connection-lines-svg');
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
return svg;
|
||||
},
|
||||
|
||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
||||
demoEntrance(what = 'all') {
|
||||
this._clearEntranceDemo();
|
||||
|
||||
// The pane is a real, shared element rather than a throwaway, so replay it
|
||||
// through the same entry point a real launch uses (bypassing the owed-id
|
||||
// check, which only exists to keep background sessions from hijacking it).
|
||||
if (what === 'term' || what === 'all') {
|
||||
const wasOnSwitch = this._termAnimOnSwitch;
|
||||
this._termAnimOnSwitch = true;
|
||||
this.playTerminalEntrance(null);
|
||||
this._termAnimOnSwitch = wasOnSwitch;
|
||||
}
|
||||
if (what === 'term') return;
|
||||
|
||||
const tabCount = what === 'window' ? 1 : 4;
|
||||
const tabs = this._demoTabs(tabCount);
|
||||
let total = Math.max(this._tabAnimDuration() + tabCount * this._tabAnimStagger, this._termAnimDuration());
|
||||
|
||||
if (what !== 'tabs') {
|
||||
// Give the tab cascade a beat, so the window reads as coming out of it.
|
||||
const lead = what === 'all' ? Math.min(320, this._tabAnimStagger * 2) : 0;
|
||||
this._animDemoWindowTimer = setTimeout(() => this._demoWindow(tabs[0]), lead);
|
||||
total = Math.max(total, lead + this._winAnimDuration() + this._lineAnimDuration() + BEAM_HOLD_MS);
|
||||
}
|
||||
|
||||
this._animDemoTimer = setTimeout(() => this._clearEntranceDemo(), total + 1800);
|
||||
},
|
||||
|
||||
/** Append `count` throwaway tabs and run the tab entrance on them. */
|
||||
_demoTabs(count) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return [];
|
||||
const made = [];
|
||||
const base = this.sessions?.size || 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.demo = '1';
|
||||
tab.dataset.color = ['green', 'blue', 'purple', 'orange', 'pink', 'yellow', 'red'][i % 7];
|
||||
tab.innerHTML = `
|
||||
<span class="tab-number">${base + i + 1}</span>
|
||||
<span class="tab-status idle" aria-hidden="true"></span>
|
||||
<span class="tab-info"><span class="tab-name-row">
|
||||
<span class="tab-name">w${base + i + 1}-demo</span>
|
||||
</span></span>`;
|
||||
container.appendChild(tab);
|
||||
made.push(tab);
|
||||
if (this._tabAnimStyle !== 'off') {
|
||||
tab.style.setProperty('--tab-enter-delay', `${i * this._tabAnimStagger}ms`);
|
||||
void tab.offsetWidth; // force layout so the class add starts a fresh run
|
||||
tab.classList.add('tab-enter');
|
||||
}
|
||||
}
|
||||
return made;
|
||||
},
|
||||
|
||||
/** Append a throwaway agent window under `originTab`, plus its connection line. */
|
||||
_demoWindow(originTab) {
|
||||
const tabRect = originTab?.getBoundingClientRect();
|
||||
const win = document.createElement('div');
|
||||
win.className = 'subagent-window';
|
||||
win.dataset.demo = '1';
|
||||
win.style.width = '360px';
|
||||
win.style.height = '220px';
|
||||
win.style.left = `${Math.max(24, (tabRect?.left ?? 120) - 40)}px`;
|
||||
win.style.top = `${(tabRect?.bottom ?? 60) + 160}px`;
|
||||
win.style.zIndex = '1001';
|
||||
win.innerHTML = `
|
||||
<div class="subagent-window-header">
|
||||
<div class="subagent-window-title"><span class="icon">🤖</span><span class="id">demo-agent</span>
|
||||
<span class="status running">running</span></div>
|
||||
</div>
|
||||
<div class="subagent-window-body"><div class="subagent-empty">Preview window</div></div>`;
|
||||
document.body.appendChild(win);
|
||||
this.applyWindowEntrance(win);
|
||||
this._demoLine(tabRect, win);
|
||||
},
|
||||
|
||||
/** Draw a fake tab→window line into the lab's own overlay and animate it. */
|
||||
_demoLine(tabRect, win) {
|
||||
if (!tabRect) return;
|
||||
const svg = this._demoLineSvg();
|
||||
const winRect = win.getBoundingClientRect();
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
|
||||
path.setAttribute('class', 'connection-line');
|
||||
path.dataset.demo = '1';
|
||||
svg.appendChild(path);
|
||||
|
||||
if ((this._lineAnimStyle || LINE_ANIM_DEFAULT) === 'off') return;
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', '0ms');
|
||||
path.classList.add('line-enter');
|
||||
|
||||
if (this._lineAnimStyle === 'packet') {
|
||||
const packet = path.cloneNode(false);
|
||||
packet.setAttribute('class', 'connection-line-packet');
|
||||
packet.dataset.demo = '1';
|
||||
packet.style.setProperty('--line-len', `${len}px`);
|
||||
packet.style.setProperty('--line-enter-delay', '0ms');
|
||||
svg.appendChild(packet);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -103,6 +103,7 @@
|
||||
'Run Claude Code': '运行 Claude Code',
|
||||
'Run OpenCode': '运行 OpenCode',
|
||||
'Run Gemini': '运行 Gemini',
|
||||
'Run Antigravity': '运行 Antigravity',
|
||||
'Run Shell': '运行 Shell',
|
||||
'Select AI backend': '选择 AI 后端',
|
||||
'Create New Case': '新建案例',
|
||||
@@ -349,6 +350,26 @@
|
||||
'Show Shortcuts': '显示快捷键',
|
||||
'Full shortcut reference': '完整快捷键参考',
|
||||
|
||||
// Mobile overview (phone home screen)
|
||||
'Needs you': '需要你',
|
||||
'Current sessions': '当前会话',
|
||||
'Past sessions': '历史会话',
|
||||
'Show all past sessions': '显示全部历史会话',
|
||||
'Show fewer': '收起',
|
||||
'Choose what to run': '选择运行方式',
|
||||
'Web / URL': '网页 / 链接',
|
||||
'Add URL…': '添加链接…',
|
||||
'Nothing running. Hit Run to start something.': '当前没有运行中的会话。点击“运行”开始。',
|
||||
'No past conversations yet': '尚无历史对话',
|
||||
'Loading…': '加载中…',
|
||||
// Status pills are deliberately NOT listed: they are single generic words
|
||||
// ("idle", "done", "error") that also appear as state strings elsewhere, so
|
||||
// they carry data-i18n-skip in the DOM instead of a translation entry here.
|
||||
'Overview Home Screen': '概览主页',
|
||||
'On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen':
|
||||
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
|
||||
Phone: '手机',
|
||||
|
||||
// Session/case dialogs
|
||||
'Session Options': '会话选项',
|
||||
'Session Name': '会话名称',
|
||||
@@ -430,6 +451,7 @@
|
||||
'Respawn Blocked': '重生已阻止',
|
||||
'Task Complete': '任务完成',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Failed to copy': '复制失败',
|
||||
'Checking…': '正在检查…',
|
||||
'Starting…': '正在启动…',
|
||||
'Starting update…': '正在开始更新…',
|
||||
@@ -579,6 +601,9 @@
|
||||
'Select a run to view its agents': '选择一次运行以查看其智能体',
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Unsaved changes': '未保存的更改',
|
||||
Saved: '已保存',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
'Export as Markdown': '导出为 Markdown',
|
||||
'Mark all read': '全部标为已读',
|
||||
|
||||
@@ -311,19 +311,19 @@
|
||||
<h1 class="welcome-title">Codeman</h1>
|
||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-btn welcome-btn-claude" onclick="app.setRunMode('claude'); app.runClaude()">
|
||||
<button class="welcome-btn welcome-btn-claude" id="welcomeClaudeBtn" style="display: none;" onclick="app.setRunMode('claude'); app.runClaude()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude Code
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" onclick="app.toggleTunnelFromWelcome()">
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-opencode" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<button class="welcome-btn welcome-btn-opencode" id="welcomeOpencodeBtn" style="display: none;" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-gemini" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<button class="welcome-btn welcome-btn-gemini" id="welcomeGeminiBtn" style="display: none;" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Gemini
|
||||
</button>
|
||||
@@ -379,6 +379,12 @@
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Overview (phone home screen, shown in place of the welcome
|
||||
overlay when the "C" logo is tapped). Ships `hidden`: only
|
||||
mobile-overview.js removes it, and only when the phone gate passes, so
|
||||
desktop (which never loads mobile.css) can never render it. -->
|
||||
<div class="mobile-overview" id="mobileOverview" hidden></div>
|
||||
</main>
|
||||
|
||||
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
||||
@@ -416,11 +422,18 @@
|
||||
<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-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" onclick="app.closeFilePreview()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-preview-body" id="filePreviewBody"></div>
|
||||
<div class="file-preview-editbar" id="filePreviewEditBar" hidden>
|
||||
<span class="file-preview-dirty" id="filePreviewDirty" hidden>Unsaved changes</span>
|
||||
<span class="file-preview-editbar-spacer"></span>
|
||||
<button class="file-preview-editbar-btn" onclick="app.cancelFilePreviewEdit()">Cancel</button>
|
||||
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
|
||||
</div>
|
||||
<div class="file-preview-footer" id="filePreviewFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,6 +477,9 @@
|
||||
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
|
||||
<span class="run-mode-dot antigravity"></span>Antigravity
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
@@ -630,6 +646,8 @@
|
||||
<section class="shortcut-section">
|
||||
<h4>Terminal</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd></div><div>Copy Selection (interrupts when nothing is selected)</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>C</kbd></div><div>Copy Selection</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
@@ -733,6 +751,7 @@
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="antigravity">Antigravity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -1276,6 +1295,20 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="How new session tabs, the terminal pane, agent windows and their connection lines appear. This device only. For per-surface control and a live preview, add ?animlab=1 to the URL.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Entrance Animations</span>
|
||||
<span class="settings-item-desc">How new tabs, panes and agent windows arrive</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="form-select settings-inline-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
|
||||
<span class="settings-item-label">WebGL Renderer</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1424,6 +1457,16 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Phone Section (only meaningful under 430px, hidden elsewhere by settings-ui.js) -->
|
||||
<div class="settings-section-header" id="appSettingsPhoneSection">Phone</div>
|
||||
<div class="settings-item" id="appSettingsMobileOverviewItem" title="On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen">
|
||||
<span class="settings-item-label">Overview Home Screen</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsMobileOverview" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
|
||||
@@ -1654,6 +1697,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Animated Status Effects</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsCodexAnimations">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Decorative Codex TUI motion for new local sessions. Leave off to reduce remote and mobile redraws.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Models Tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-models">
|
||||
@@ -1848,7 +1899,7 @@
|
||||
<input type="checkbox" id="eventIdleAudio">
|
||||
|
||||
<div class="event-label">Response complete</div>
|
||||
<input type="checkbox" id="eventStopEnabled" checked>
|
||||
<input type="checkbox" id="eventStopEnabled">
|
||||
<input type="checkbox" id="eventStopBrowser">
|
||||
<input type="checkbox" id="eventStopPush">
|
||||
<input type="checkbox" id="eventStopAudio">
|
||||
@@ -2626,6 +2677,8 @@
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
/**
|
||||
* @fileoverview Phone home screen: a scrolling overview of what every session is
|
||||
* doing, shown instead of the welcome overlay when the "C" logo is tapped.
|
||||
*
|
||||
* The welcome screen answers "how do I start something"; on a phone the more
|
||||
* urgent question is "which of my sessions is blocked on me". This surface
|
||||
* answers that first: NEEDS YOU (pending permission/question/idle hooks and
|
||||
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
||||
* WORKING and IDLE / DONE.
|
||||
*
|
||||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
||||
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
||||
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
||||
* only this module removes it, so desktop (which never loads mobile.css) cannot
|
||||
* render an unstyled overview even if a class rule leaked.
|
||||
*
|
||||
* Everything renders from state the page already holds (`this.sessions`,
|
||||
* `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema.
|
||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
* @dependency session-ui.js (selectQuickStartCase for "New session here")
|
||||
* @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
||||
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
||||
|
||||
/** Sort rank per state: the most demanding thing sorts first inside a section. */
|
||||
const MOBILE_OVERVIEW_STATE_RANK = {
|
||||
needs: 0,
|
||||
error: 1,
|
||||
waiting: 2,
|
||||
working: 3,
|
||||
idle: 4,
|
||||
done: 5,
|
||||
};
|
||||
|
||||
/** How many past conversations show before the "Show all" toggle. */
|
||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
|
||||
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
|
||||
*/
|
||||
const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
|
||||
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
|
||||
{ mode: 'codex', label: 'Codex', short: 'Codex' },
|
||||
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
|
||||
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
error: 'error',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Model (pure)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Classify one session.
|
||||
* Order matters: an action hook outranks everything (it is literally blocking
|
||||
* the agent), and a pending idle_prompt outranks a stale 'busy' status because
|
||||
* the hook is the newer signal.
|
||||
* @param {object} session session state from this.sessions
|
||||
* @param {Set<string>|undefined} hooks pending hook types for that session
|
||||
* @returns {'needs'|'error'|'waiting'|'working'|'idle'|'done'}
|
||||
*/
|
||||
_mobileOverviewState(session, hooks) {
|
||||
if (hooks && (hooks.has('permission_prompt') || hooks.has('elicitation_dialog'))) return 'needs';
|
||||
if (session.status === 'error') return 'error';
|
||||
if (hooks && hooks.has('idle_prompt')) return 'waiting';
|
||||
if (session.status === 'busy') return 'working';
|
||||
if (session.status === 'stopped') return 'done';
|
||||
return 'idle';
|
||||
},
|
||||
|
||||
/**
|
||||
* Longest-prefix match of a workingDir against the case list, so a session
|
||||
* started in a subdirectory still belongs to its case. Mirrors the matching in
|
||||
* `_resolveCaseLabel()` (terminal-ui.js) but returns the case itself.
|
||||
* @returns {object|null} the matching case, or null when the dir is outside every case
|
||||
*/
|
||||
_mobileOverviewCaseFor(workingDir, cases) {
|
||||
if (!workingDir) return null;
|
||||
let best = null;
|
||||
for (const c of cases || []) {
|
||||
if (!c || !c.path) continue;
|
||||
if (workingDir === c.path) return c;
|
||||
if (workingDir.startsWith(c.path + '/') && (!best || c.path.length > best.path.length)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the whole overview model. PURE: reads only its argument, touches no DOM
|
||||
* and no `this` state, so it can be unit-tested against plain objects.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {Map<string, object>|Array} input.sessions live sessions (this.sessions)
|
||||
* @param {Array} input.cases case list (this.cases)
|
||||
* @param {Array<string>} [input.sessionOrder] the user's tab order, used as the tiebreak
|
||||
* @param {Map<string, Set<string>>} [input.pendingHooks] this.pendingHooks
|
||||
* @param {Array} [input.history] unified session items (GET /api/sessions/unified)
|
||||
* @returns {{needsYou: Array, current: Array, past: Array, sessionCount: number}}
|
||||
*/
|
||||
buildMobileOverviewModel(input) {
|
||||
const cases = Array.isArray(input && input.cases) ? input.cases : [];
|
||||
const order = Array.isArray(input && input.sessionOrder) ? input.sessionOrder : [];
|
||||
const pendingHooks = (input && input.pendingHooks) || new Map();
|
||||
const raw = (input && input.sessions) || [];
|
||||
const sessions = typeof raw.values === 'function' ? Array.from(raw.values()) : Array.from(raw);
|
||||
|
||||
const rows = sessions.map((session) => {
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
|
||||
const orderIndex = order.indexOf(session.id);
|
||||
return {
|
||||
id: session.id,
|
||||
name: this.getSessionName ? this.getSessionName(session) : session.name || session.id.slice(0, 8),
|
||||
mode: session.mode || 'claude',
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const bySeverityThenOrder = (a, b) => {
|
||||
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
|
||||
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
|
||||
};
|
||||
|
||||
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
|
||||
|
||||
// Past = conversations from the unified list that are not currently live.
|
||||
// The endpoint already folds a transcript into its owning session (via the
|
||||
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
||||
// a running session twice.
|
||||
const liveIds = new Set(rows.map((r) => r.id));
|
||||
const past = (Array.isArray(input && input.history) ? input.history : [])
|
||||
.filter((item) => item && item.sessionId && !liveIds.has(item.sessionId))
|
||||
.map((item) => {
|
||||
const matched = this._mobileOverviewCaseFor(item.workingDir, cases);
|
||||
const dir = item.workingDir || '';
|
||||
// The transcript reader emits the literal "(no content)" for a
|
||||
// conversation it could not pull a prompt from; that is not a title.
|
||||
const prompt = (item.firstPrompt || '').trim();
|
||||
const title = prompt && prompt !== '(no content)' ? prompt : '';
|
||||
return {
|
||||
id: item.sessionId,
|
||||
claudeSessionId: item.claudeSessionId || '',
|
||||
workingDir: dir,
|
||||
name: item.name || '',
|
||||
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
|
||||
mode: item.mode || 'claude',
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
|
||||
at: item.lastActivityAt || item.createdAt || 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.at - a.at);
|
||||
|
||||
return {
|
||||
needsYou: inSection(['needs', 'error', 'waiting']),
|
||||
current: inSection(['working', 'idle', 'done']),
|
||||
past,
|
||||
sessionCount: rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Gate + visibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Phone-only gate. Width-driven (not `isHandheldDevice()`): this is a LAYOUT
|
||||
* decision, and an unfolded foldable with a tablet-width viewport should get
|
||||
* the tablet welcome screen. Per-device settings identity is a separate
|
||||
* question and deliberately stays handheld-based.
|
||||
*/
|
||||
shouldUseMobileOverview() {
|
||||
if (this.isSoloWindow) return false;
|
||||
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
|
||||
if (settings.mobileOverviewEnabled === false) return false;
|
||||
if (typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType) {
|
||||
return MobileDetection.getDeviceType() === 'mobile';
|
||||
}
|
||||
return !!(window.matchMedia && window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY).matches);
|
||||
},
|
||||
|
||||
/** True while the overview is the visible home surface. */
|
||||
isMobileOverviewVisible() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
return !!el && el.classList.contains('visible');
|
||||
},
|
||||
|
||||
showMobileOverview() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
el.hidden = false;
|
||||
el.classList.add('visible');
|
||||
this._wireMobileOverview(el);
|
||||
this.renderMobileOverview();
|
||||
void this.loadMobileOverviewHistory();
|
||||
},
|
||||
|
||||
hideMobileOverview() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
this._closeMobileOverviewRunMenu();
|
||||
el.classList.remove('visible');
|
||||
el.hidden = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Past conversations, fetched once per home-screen visit. The unified list is
|
||||
* the same source the welcome screen resumes from, so a row resumed here and a
|
||||
* row resumed there behave identically. Failures leave the section out rather
|
||||
* than showing an error: the live sessions above it are the important part.
|
||||
*/
|
||||
async loadMobileOverviewHistory() {
|
||||
if (this._mobileOverviewHistoryLoading) return;
|
||||
this._mobileOverviewHistoryLoading = true;
|
||||
try {
|
||||
this._mobileOverviewHistory = await this._fetchUnifiedSessions(60);
|
||||
} catch (err) {
|
||||
console.warn('[mobile-overview] history load failed:', err);
|
||||
this._mobileOverviewHistory = this._mobileOverviewHistory || [];
|
||||
} finally {
|
||||
this._mobileOverviewHistoryLoading = false;
|
||||
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
|
||||
}
|
||||
},
|
||||
|
||||
/** Re-render only when the surface is actually showing (called from the tab renderer). */
|
||||
_refreshMobileOverviewIfVisible() {
|
||||
if (!this.isMobileOverviewVisible()) return;
|
||||
this._debouncedCall('mobileOverview', () => this.renderMobileOverview(), 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row, plus a breakpoint listener so
|
||||
* rotating or unfolding while on the home screen swaps to the right surface
|
||||
* instead of stranding a phone layout on a tablet-width viewport.
|
||||
*/
|
||||
_wireMobileOverview(el) {
|
||||
if (this._mobileOverviewWired) return;
|
||||
this._mobileOverviewWired = true;
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
|
||||
if (!target) return;
|
||||
const action = target.dataset.moAction;
|
||||
if (action === 'session') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.selectSession(target.dataset.moSession);
|
||||
} else if (action === 'resume') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.resumeMobileOverviewSession(target.dataset.moSession);
|
||||
} else if (action === 'more-past') {
|
||||
this._mobileOverviewShowAllPast = !this._mobileOverviewShowAllPast;
|
||||
this.renderMobileOverview();
|
||||
} else if (action === 'run') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.run();
|
||||
} else if (action === 'run-menu') {
|
||||
this._toggleMobileOverviewRunMenu();
|
||||
} else if (action === 'run-mode') {
|
||||
// Picking a backend both selects it (so the Run button keeps meaning what
|
||||
// you last chose, exactly like the toolbar) and launches it: on a phone
|
||||
// the pick IS the intent to start.
|
||||
this._closeMobileOverviewRunMenu();
|
||||
this.setRunMode(target.dataset.moMode);
|
||||
void this.run();
|
||||
} else if (action === 'run-webview') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.openWebviewFromMenu(target.dataset.moWebview);
|
||||
} else if (action === 'run-add-url') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
this.showWebviewModal();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
_toggleMobileOverviewRunMenu() {
|
||||
this._mobileOverviewRunMenuOpen = !this._mobileOverviewRunMenuOpen;
|
||||
this.renderMobileOverview();
|
||||
},
|
||||
|
||||
_closeMobileOverviewRunMenu() {
|
||||
if (!this._mobileOverviewRunMenuOpen) return;
|
||||
this._mobileOverviewRunMenuOpen = false;
|
||||
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
|
||||
},
|
||||
|
||||
/**
|
||||
* Resume a past conversation. Delegates to the same resumeHistorySession() the
|
||||
* welcome screen's Resume list uses, so name synthesis, envOverrides and the
|
||||
* resumeSessionId wiring stay in one place.
|
||||
*/
|
||||
async resumeMobileOverviewSession(sessionId) {
|
||||
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
|
||||
if (!row || !row.workingDir) return;
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Render
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderMobileOverview() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
|
||||
const model = this.buildMobileOverviewModel({
|
||||
sessions: this.sessions,
|
||||
cases: this.cases,
|
||||
sessionOrder: this.sessionOrder,
|
||||
pendingHooks: this.pendingHooks,
|
||||
history: this._mobileOverviewHistory,
|
||||
});
|
||||
// Resume needs the workingDir/claudeSessionId off the row the user tapped.
|
||||
this._mobileOverviewPastRows = model.past;
|
||||
|
||||
el.replaceChildren();
|
||||
el.appendChild(this._buildMobileOverviewTop());
|
||||
|
||||
if (model.needsYou.length) {
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Needs you',
|
||||
model.needsYou.length,
|
||||
model.needsYou.map((r) => this._buildMobileOverviewRow(r))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Current sessions',
|
||||
model.current.length,
|
||||
model.current.map((r) => this._buildMobileOverviewRow(r)),
|
||||
'Nothing running. Hit Run to start something.'
|
||||
)
|
||||
);
|
||||
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Past sessions',
|
||||
model.past.length,
|
||||
this._buildMobileOverviewPast(model),
|
||||
this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…'
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Past conversations, newest first and capped: the unified list can run to
|
||||
* dozens, and this section sits below the live ones on purpose.
|
||||
*/
|
||||
_buildMobileOverviewPast(model) {
|
||||
const showAll = !!this._mobileOverviewShowAllPast;
|
||||
const visible = showAll ? model.past : model.past.slice(0, MOBILE_OVERVIEW_PAST_LIMIT);
|
||||
const children = visible.map((r) => this._buildMobileOverviewPastRow(r));
|
||||
const hiddenCount = model.past.length - visible.length;
|
||||
if (hiddenCount > 0 || showAll) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'mobile-overview-more';
|
||||
toggle.dataset.moAction = 'more-past';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = showAll ? 'Show fewer' : 'Show all past sessions';
|
||||
toggle.appendChild(label);
|
||||
if (!showAll) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'mobile-overview-more-count';
|
||||
count.setAttribute('data-i18n-skip', '');
|
||||
count.textContent = String(hiddenCount);
|
||||
toggle.appendChild(count);
|
||||
}
|
||||
children.push(toggle);
|
||||
}
|
||||
return children;
|
||||
},
|
||||
|
||||
_buildMobileOverviewTop() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mobile-overview-header';
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'mobile-overview-top';
|
||||
|
||||
const brand = document.createElement('span');
|
||||
brand.className = 'mobile-overview-brand';
|
||||
brand.textContent = (window.CodemanI18n && window.CodemanI18n.displayName) || 'Codeman';
|
||||
brand.setAttribute('data-i18n-skip', '');
|
||||
top.appendChild(brand);
|
||||
|
||||
// Split button carrying the TOOLBAR's own classes (`btn-toolbar btn-run
|
||||
// mode-<mode>` / `btn-run-gear`), so the per-backend gradient, border and
|
||||
// text color come from the same rules as the Run button in the toolbar and
|
||||
// stay in sync with it for free. mobile.css only sizes it.
|
||||
const group = document.createElement('div');
|
||||
group.className = 'mobile-overview-run-group';
|
||||
|
||||
const mode = this.runMode || 'claude';
|
||||
const run = document.createElement('button');
|
||||
run.className = `btn-toolbar btn-run mode-${mode} mobile-overview-run`;
|
||||
run.type = 'button';
|
||||
run.dataset.moAction = 'run';
|
||||
const runLabel = document.createElement('span');
|
||||
runLabel.textContent = 'Run';
|
||||
run.appendChild(runLabel);
|
||||
const runMode = document.createElement('span');
|
||||
runMode.className = 'mobile-overview-run-mode';
|
||||
runMode.setAttribute('data-i18n-skip', '');
|
||||
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
|
||||
run.appendChild(runMode);
|
||||
group.appendChild(run);
|
||||
|
||||
const caret = document.createElement('button');
|
||||
caret.className = `btn-toolbar btn-run-gear mode-${mode} mobile-overview-run-caret`;
|
||||
caret.type = 'button';
|
||||
caret.dataset.moAction = 'run-menu';
|
||||
caret.setAttribute('aria-label', 'Choose what to run');
|
||||
caret.setAttribute('aria-expanded', String(!!this._mobileOverviewRunMenuOpen));
|
||||
// An SVG chevron, not a "⌄" glyph: the character carries its own baseline
|
||||
// offset, so it sits visibly low in a flex-centered box no matter what the
|
||||
// line-height says. A path is centered by geometry. Same shape the toolbar's
|
||||
// run-mode gear uses.
|
||||
caret.appendChild(this._buildMobileOverviewChevron());
|
||||
group.appendChild(caret);
|
||||
|
||||
top.appendChild(group);
|
||||
wrap.appendChild(top);
|
||||
|
||||
if (this._mobileOverviewRunMenuOpen) wrap.appendChild(this._buildMobileOverviewRunMenu());
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/** Down chevron as SVG (see the note at its call site). */
|
||||
_buildMobileOverviewChevron() {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2.5');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M6 9l6 6 6-6');
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
},
|
||||
|
||||
/**
|
||||
* The Run picker: the same backends as the toolbar's run-mode menu, plus saved
|
||||
* web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu:
|
||||
* past conversations have their own section further down this screen.
|
||||
*
|
||||
* Gated the same way as the toolbar's #runModeMenu (isCliAvailable(), shell
|
||||
* exempt) — this list is a separate, hardcoded duplicate of the toolbar's menu
|
||||
* rather than a shared render, so it never picked up #201's gating and offered
|
||||
* every backend regardless of what's actually installed.
|
||||
*/
|
||||
_buildMobileOverviewRunMenu() {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'mobile-overview-run-menu';
|
||||
const current = this.runMode || 'claude';
|
||||
|
||||
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
|
||||
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : '');
|
||||
option.dataset.moAction = 'run-mode';
|
||||
option.dataset.moMode = entry.mode;
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'run-mode-dot ' + entry.mode;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
option.appendChild(dot);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = entry.label;
|
||||
option.appendChild(label);
|
||||
menu.appendChild(option);
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mobile-overview-run-header';
|
||||
header.textContent = 'Web / URL';
|
||||
menu.appendChild(header);
|
||||
|
||||
for (const webview of this.webviews ? this.webviews.values() : []) {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'mobile-overview-run-option';
|
||||
option.dataset.moAction = 'run-webview';
|
||||
option.dataset.moWebview = webview.id;
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'run-mode-dot web';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
option.appendChild(dot);
|
||||
const label = document.createElement('span');
|
||||
// A dashboard name is user content.
|
||||
label.className = 'case-name';
|
||||
label.textContent = webview.name;
|
||||
option.appendChild(label);
|
||||
menu.appendChild(option);
|
||||
}
|
||||
|
||||
const add = document.createElement('button');
|
||||
add.type = 'button';
|
||||
add.className = 'mobile-overview-run-option mobile-overview-run-option--add';
|
||||
add.dataset.moAction = 'run-add-url';
|
||||
const addDot = document.createElement('span');
|
||||
addDot.className = 'run-mode-dot web';
|
||||
addDot.setAttribute('aria-hidden', 'true');
|
||||
add.appendChild(addDot);
|
||||
const addLabel = document.createElement('span');
|
||||
addLabel.textContent = 'Add URL…';
|
||||
add.appendChild(addLabel);
|
||||
menu.appendChild(add);
|
||||
|
||||
return menu;
|
||||
},
|
||||
|
||||
_buildMobileOverviewSection(title, count, children, emptyText) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'mobile-overview-section';
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.className = 'mobile-overview-heading';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = title;
|
||||
heading.appendChild(label);
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'mobile-overview-heading-count';
|
||||
badge.textContent = String(count);
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
heading.appendChild(badge);
|
||||
section.appendChild(heading);
|
||||
|
||||
if (!children.length && emptyText) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'mobile-overview-empty';
|
||||
empty.textContent = emptyText;
|
||||
section.appendChild(empty);
|
||||
return section;
|
||||
}
|
||||
for (const child of children) section.appendChild(child);
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* A session row. The state class drives the same visual language as the
|
||||
* session tabs: green dot when it is fine (pulsing while working), a yellow
|
||||
* blinking row when it wants input, a red blinking row when it asked a
|
||||
* question. Anything else here would mean two different meanings for the same
|
||||
* colors on one screen.
|
||||
*/
|
||||
_buildMobileOverviewRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
|
||||
item.dataset.moAction = 'session';
|
||||
item.dataset.moSession = row.id;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'mobile-overview-row-body';
|
||||
|
||||
const line1 = document.createElement('span');
|
||||
line1.className = 'mobile-overview-row-title';
|
||||
const name = document.createElement('span');
|
||||
// .session-name is in the i18n skip list: a session name is user content.
|
||||
name.className = 'session-name';
|
||||
name.textContent = row.name;
|
||||
line1.appendChild(name);
|
||||
if (row.caseName) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'mobile-overview-row-case case-name';
|
||||
meta.textContent = ' · ' + row.caseName;
|
||||
line1.appendChild(meta);
|
||||
}
|
||||
body.appendChild(line1);
|
||||
|
||||
const line2 = document.createElement('span');
|
||||
line2.className = 'mobile-overview-row-sub';
|
||||
line2.setAttribute('data-i18n-skip', '');
|
||||
line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : '');
|
||||
body.appendChild(line2);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
|
||||
// Skipped by i18n on purpose: the labels are generic single words ("idle",
|
||||
// "done", "error") that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
pill.textContent = row.pill;
|
||||
item.appendChild(pill);
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'mobile-overview-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.textContent = '›';
|
||||
item.appendChild(chevron);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
/** A past conversation. Tapping it resumes, which creates a fresh session. */
|
||||
_buildMobileOverviewPastRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mobile-overview-row mobile-overview-row--past';
|
||||
item.dataset.moAction = 'resume';
|
||||
item.dataset.moSession = row.id;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--past';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'mobile-overview-row-body';
|
||||
|
||||
const title = document.createElement('span');
|
||||
// A first prompt is user content, never app copy.
|
||||
title.className = 'mobile-overview-row-title session-name';
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'mobile-overview-row-sub';
|
||||
sub.setAttribute('data-i18n-skip', '');
|
||||
const when = row.at && this._formatTimeAgo ? this._formatTimeAgo(row.at) : '';
|
||||
sub.textContent = [row.caseName || row.dir, when].filter(Boolean).join(' · ');
|
||||
body.appendChild(sub);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--past';
|
||||
pill.textContent = 'resume';
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
item.appendChild(pill);
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'mobile-overview-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.textContent = '›';
|
||||
item.appendChild(chevron);
|
||||
|
||||
return item;
|
||||
},
|
||||
});
|
||||
@@ -839,6 +839,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
/* Antigravity mode colors on mobile */
|
||||
.btn-toolbar.btn-run.mode-antigravity,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity {
|
||||
background: #0b2b33;
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
color: #cffafe;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-antigravity:active,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity:active {
|
||||
background: #0e7490;
|
||||
border-color: rgba(34, 211, 238, 0.5);
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -1857,6 +1871,33 @@ html.mobile-init .file-browser-panel {
|
||||
bottom: calc(44px + 2rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* File preview window: full screen on phones. --app-height tracks the visual
|
||||
viewport (KeyboardHandler), so the edit textarea + Save bar stay above the
|
||||
OS keyboard instead of hiding behind it. Footer/edit bar pad for the home
|
||||
indicator. */
|
||||
.file-preview-window {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: var(--app-height, 100vh);
|
||||
max-height: var(--app-height, 100vh);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.file-preview-editbar {
|
||||
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
.file-preview-overlay .file-preview-footer {
|
||||
padding-bottom: calc(0.35rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* >=16px or iOS Safari auto-zooms the page on focus */
|
||||
.file-preview-body textarea.file-preview-editor {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Notification drawer - full width on mobile, includes safe area padding */
|
||||
.notification-drawer {
|
||||
width: 100%;
|
||||
@@ -2245,6 +2286,433 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 8px;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ---- Mobile Overview (phone home screen) ----
|
||||
Shown in place of the welcome overlay when the C logo is tapped. Styled only
|
||||
with :root tokens so every skin, including the four light ones, works with
|
||||
no override block. Never give .mobile-overview itself a display value: the
|
||||
element ships with [hidden] and only .visible may turn it on. */
|
||||
|
||||
.mobile-overview.visible {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.6rem calc(1rem + var(--safe-area-bottom));
|
||||
background: var(--bg-dark);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* No side padding: the wordmark's left edge lines up with the session cards
|
||||
below it, and the Run button's right edge with theirs. */
|
||||
.mobile-overview-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Same accent as the "C" in the header (`.logo` uses --accent-hover), and the
|
||||
same 48px box as the Run button opposite it, so the two are centered on one
|
||||
line by construction rather than by eye. */
|
||||
.mobile-overview-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--accent-hover);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Split Run button. Colors come from the toolbar's own
|
||||
`.btn-toolbar.btn-run.mode-<backend>` rules in styles.css (the element
|
||||
carries those classes), so this button and the one in the toolbar are the
|
||||
same control in two places. Only size/shape is set here, and NOTHING that
|
||||
would override the mode gradient. */
|
||||
.mobile-overview-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-overview-run-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sized well past the 44px touch minimum: this is the primary action on the
|
||||
home screen, and the caret is a separate target right next to it.
|
||||
⚠️ The !important is required, not decorative: the phone block clamps every
|
||||
.btn-toolbar to `height/min-height/max-height: 26px !important`, and this
|
||||
button deliberately carries .btn-toolbar to inherit the run-mode gradient.
|
||||
Without matching !important (max-height included) it renders 26px tall. */
|
||||
.mobile-overview-run-group .mobile-overview-run {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 48px !important;
|
||||
height: 48px !important;
|
||||
max-height: 48px !important;
|
||||
padding: 0 1.15rem !important;
|
||||
font-size: 0.95rem !important;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
border-radius: 10px 0 0 10px !important;
|
||||
}
|
||||
|
||||
.mobile-overview-run-mode {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mobile-overview-run-group .mobile-overview-run-caret {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px !important;
|
||||
height: 48px !important;
|
||||
max-height: 48px !important;
|
||||
min-width: 48px !important;
|
||||
width: 48px !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
border-radius: 0 10px 10px 0 !important;
|
||||
}
|
||||
|
||||
/* The phone block shrinks every run-gear glyph to 10px; this one is a 48px
|
||||
tap target, so it gets a proportionate chevron. `display:block` drops the
|
||||
inline-baseline gap that would otherwise push it a pixel low. */
|
||||
.mobile-overview-run-group .mobile-overview-run-caret svg {
|
||||
display: block;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-overview-run-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--floating-bg);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
}
|
||||
|
||||
.mobile-overview-run-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
padding: 0 0.6rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-overview-run-option.selected {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.mobile-overview-run-option:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-overview-run-option--add {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-overview-run-header {
|
||||
padding: 0.4rem 0.6rem 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Flush left with the wordmark and the cards: one left edge down the screen. */
|
||||
.mobile-overview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-heading-count {
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-overview-empty {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Row: 56px tap target, dot + two-line body + pill + chevron */
|
||||
.mobile-overview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-overview-row:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Attention states mirror the session tabs exactly: red blink when the agent
|
||||
asked something (permission / question), yellow blink when it is waiting for
|
||||
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
|
||||
styles.css; the keyframes are re-declared here only because a tab's resting
|
||||
background is transparent while a row's is the card color. */
|
||||
.mobile-overview-row--needs {
|
||||
border-color: var(--red);
|
||||
animation: mobile-overview-blink-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mobile-overview-row--waiting {
|
||||
border-color: var(--yellow);
|
||||
animation: mobile-overview-blink-yellow 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mobile-overview-row--error {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
@keyframes mobile-overview-blink-red {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
50% {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border-color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-overview-blink-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
50% {
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-overview-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-overview-row-title {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-overview-row-case {
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-overview-row-sub {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-overview-dot {
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--needs,
|
||||
.mobile-overview-dot--error {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--waiting {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
/* Same as .session-tab .tab-status: green when the session is fine, and the
|
||||
shared `pulse` keyframes while it is working. */
|
||||
.mobile-overview-dot--working {
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.mobile-overview-dot--idle {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--done {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mobile-overview-pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-overview-pill--needs,
|
||||
.mobile-overview-pill--error {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.mobile-overview-pill--waiting {
|
||||
border-color: var(--yellow);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.mobile-overview-pill--idle,
|
||||
.mobile-overview-pill--working {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.mobile-overview-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Past conversations: quieter than a live row, since tapping one starts work */
|
||||
.mobile-overview-row--past {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.mobile-overview-row--past .mobile-overview-row-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--past {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.mobile-overview-pill--past {
|
||||
border-color: var(--border-light);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--control-bg);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-overview-more-count {
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* The overview's attention blink is an alert, so it stays visible without
|
||||
motion: hold the alert color instead of animating to it. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-overview-row--needs,
|
||||
.mobile-overview-row--waiting {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.mobile-overview-row--needs {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.mobile-overview-row--waiting {
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--working {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||
@@ -2286,6 +2754,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-antigravity, .btn-toolbar.btn-run-gear.mode-antigravity) {
|
||||
background: linear-gradient(135deg, #0e7490, #0891b2);
|
||||
border-color: #155e75;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
||||
border-left-color: var(--control-border-hover) !important;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
*
|
||||
* Features:
|
||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
|
||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
|
||||
* - Device-specific defaults (notifications disabled on mobile by default)
|
||||
* - 5s notification grouping window to batch rapid-fire events
|
||||
* - 100-notification cap with oldest eviction
|
||||
@@ -21,7 +21,7 @@
|
||||
* @param {CodemanApp} app - Reference to the main app instance
|
||||
*
|
||||
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
||||
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
|
||||
* @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
|
||||
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
|
||||
*/
|
||||
|
||||
@@ -65,12 +65,19 @@ class NotificationManager {
|
||||
});
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
_usesMobilePreferences() {
|
||||
return (
|
||||
MobileDetection.isHandheldDevice?.() ??
|
||||
MobileDetection.getDeviceType() === 'mobile'
|
||||
);
|
||||
}
|
||||
|
||||
getDefaultPreferences() {
|
||||
const defaultEventTypes = {
|
||||
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
|
||||
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
|
||||
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
|
||||
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||
stop: { enabled: false, browser: false, audio: false, push: false },
|
||||
session_error: { enabled: true, browser: true, audio: false, push: false },
|
||||
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
|
||||
token_milestone: { enabled: true, browser: false, audio: false, push: false },
|
||||
@@ -80,8 +87,8 @@ class NotificationManager {
|
||||
};
|
||||
|
||||
// Device-specific defaults: mobile has notifications disabled by default
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
const defaults = {
|
||||
const isMobile = this._usesMobilePreferences();
|
||||
return {
|
||||
enabled: !isMobile, // Disabled on mobile by default
|
||||
browserNotifications: !isMobile,
|
||||
audioAlerts: false,
|
||||
@@ -92,51 +99,97 @@ class NotificationManager {
|
||||
muteInfo: false,
|
||||
// Per-event-type preferences
|
||||
eventTypes: defaultEventTypes,
|
||||
_version: 4,
|
||||
_version: 5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the complete v1→v5 migration to either local or server-hydrated
|
||||
* preferences. Keeping one normalization path prevents fresh browsers from
|
||||
* reviving retired drawer-only hook defaults.
|
||||
*/
|
||||
normalizePreferences(rawPreferences) {
|
||||
const defaults = this.getDefaultPreferences();
|
||||
if (
|
||||
!rawPreferences ||
|
||||
typeof rawPreferences !== 'object' ||
|
||||
Array.isArray(rawPreferences)
|
||||
) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const prefs = {
|
||||
...rawPreferences,
|
||||
eventTypes:
|
||||
rawPreferences.eventTypes &&
|
||||
typeof rawPreferences.eventTypes === 'object' &&
|
||||
!Array.isArray(rawPreferences.eventTypes)
|
||||
? Object.fromEntries(
|
||||
Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
|
||||
key,
|
||||
value && typeof value === 'object' ? { ...value } : value,
|
||||
])
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
const version = Number.isInteger(prefs._version) ? prefs._version : 0;
|
||||
|
||||
// Migrate: v1 had browserNotifications defaulting to false
|
||||
if (version < 2) {
|
||||
prefs.browserNotifications = true;
|
||||
}
|
||||
// Migrate: v2 -> v3 adds eventTypes
|
||||
if (version < 3) {
|
||||
prefs.eventTypes = { ...defaults.eventTypes };
|
||||
}
|
||||
// Migrate: v3 -> v4 adds push field to all eventTypes
|
||||
if (version < 4 && prefs.eventTypes) {
|
||||
for (const key of Object.keys(prefs.eventTypes)) {
|
||||
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
||||
prefs.eventTypes[key].push = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Migrate: v4 -> v5 removes the drawer-only Response Complete default.
|
||||
// Preserve users who opted into any external delivery channel.
|
||||
if (version < 5) {
|
||||
const stopPref = prefs.eventTypes?.stop;
|
||||
if (
|
||||
stopPref?.enabled === true &&
|
||||
!stopPref.browser &&
|
||||
!stopPref.audio &&
|
||||
!stopPref.push
|
||||
) {
|
||||
stopPref.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...prefs,
|
||||
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
||||
_version: 5,
|
||||
};
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
try {
|
||||
const storageKey = this.getStorageKey();
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const prefs = JSON.parse(saved);
|
||||
// Migrate: v1 had browserNotifications defaulting to false
|
||||
if (!prefs._version || prefs._version < 2) {
|
||||
prefs.browserNotifications = true;
|
||||
prefs._version = 2;
|
||||
}
|
||||
// Migrate: v2 -> v3 adds eventTypes
|
||||
if (prefs._version < 3) {
|
||||
prefs.eventTypes = defaultEventTypes;
|
||||
prefs._version = 3;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
// Migrate: v3 -> v4 adds push field to all eventTypes
|
||||
if (prefs._version < 4) {
|
||||
if (prefs.eventTypes) {
|
||||
for (const key of Object.keys(prefs.eventTypes)) {
|
||||
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
||||
prefs.eventTypes[key].push = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs._version = 4;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
// Merge with defaults to ensure all eventTypes exist
|
||||
return {
|
||||
...defaults,
|
||||
...prefs,
|
||||
eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
|
||||
};
|
||||
const normalized = this.normalizePreferences(JSON.parse(saved));
|
||||
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
return defaults;
|
||||
return this.getDefaultPreferences();
|
||||
}
|
||||
|
||||
// Get storage key for notification prefs (device-specific)
|
||||
getStorageKey() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
|
||||
return this._usesMobilePreferences()
|
||||
? 'codeman-notification-prefs-mobile'
|
||||
: 'codeman-notification-prefs';
|
||||
}
|
||||
|
||||
savePreferences() {
|
||||
@@ -163,8 +216,10 @@ class NotificationManager {
|
||||
'exit-gate': 'ralph_complete',
|
||||
'subagent-spawn': 'subagent_spawn',
|
||||
'subagent-complete': 'subagent_complete',
|
||||
'hook-teammate-idle': 'idle_prompt',
|
||||
'hook-task-completed': 'stop',
|
||||
// Team lifecycle hooks are agent activity, not session-idle/stop alerts.
|
||||
// Reuse the existing opt-in agent categories instead of making them noisy.
|
||||
'hook-teammate-idle': 'subagent_spawn',
|
||||
'hook-task-completed': 'subagent_complete',
|
||||
};
|
||||
const eventTypeKey = categoryToEventType[category] || category;
|
||||
|
||||
|
||||
+186
-3
@@ -426,7 +426,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
@@ -3200,6 +3200,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (!overlay || !bodyEl) return;
|
||||
|
||||
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||
this._resetFilePreviewEdit();
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
titleEl.textContent = filePath;
|
||||
@@ -3298,6 +3301,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||
const truncNote = data.truncated ? ` (showing 500/${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).
|
||||
if (data.editable) {
|
||||
this.filePreviewEditTarget = { sessionId, filePath };
|
||||
const editBtn = this.$('filePreviewEditBtn');
|
||||
if (editBtn) editBtn.hidden = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to preview file:', err);
|
||||
@@ -3306,6 +3316,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeFilePreview() {
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
this._resetFilePreviewEdit();
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -3313,6 +3325,172 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewContent = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_resetFilePreviewEdit() {
|
||||
this.filePreviewEdit = null;
|
||||
this.filePreviewEditTarget = null;
|
||||
const editBtn = this.$('filePreviewEditBtn');
|
||||
if (editBtn) editBtn.hidden = true;
|
||||
const editBar = this.$('filePreviewEditBar');
|
||||
if (editBar) editBar.hidden = true;
|
||||
const dirtyEl = this.$('filePreviewDirty');
|
||||
if (dirtyEl) dirtyEl.hidden = true;
|
||||
const saveBtn = this.$('filePreviewSaveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Save';
|
||||
}
|
||||
},
|
||||
|
||||
async enterFilePreviewEdit() {
|
||||
const target = this.filePreviewEditTarget;
|
||||
if (!target || this.filePreviewEdit) return;
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
const footerEl = this.$('filePreviewFooter');
|
||||
if (!bodyEl) return;
|
||||
|
||||
// Always re-fetch with edit=1: the preview buffer may be line-truncated and
|
||||
// a truncated buffer must never become an edit buffer. Parse the envelope
|
||||
// even on non-ok responses so the specific refusal ("too large to edit
|
||||
// here") reaches the toast instead of a generic failure.
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${target.sessionId}/file-content?path=${encodeURIComponent(target.filePath)}&edit=1`
|
||||
);
|
||||
const result = await res.json().catch(() => null);
|
||||
if (!result || result.success !== true) {
|
||||
throw new Error(result?.error || `Failed to load file for editing (HTTP ${res.status})`);
|
||||
}
|
||||
data = result.data;
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.filePreviewEdit = {
|
||||
sessionId: target.sessionId,
|
||||
filePath: target.filePath,
|
||||
baseHash: data.hash,
|
||||
eol: data.eol,
|
||||
original: data.content,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'file-preview-editor';
|
||||
textarea.spellcheck = false;
|
||||
textarea.setAttribute('autocapitalize', 'off');
|
||||
textarea.setAttribute('autocorrect', 'off');
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.wrap = 'off';
|
||||
textarea.value = data.content;
|
||||
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||
bodyEl.innerHTML = '';
|
||||
bodyEl.appendChild(textarea);
|
||||
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||
// before the user has scrolled to the line they want to change.
|
||||
|
||||
const editBtn = this.$('filePreviewEditBtn');
|
||||
if (editBtn) editBtn.hidden = true;
|
||||
const editBar = this.$('filePreviewEditBar');
|
||||
if (editBar) editBar.hidden = false;
|
||||
if (footerEl) {
|
||||
const eolNote = data.eol === 'crlf' ? ' • CRLF' : '';
|
||||
footerEl.textContent = `Editing • ${data.totalLines} lines • ${this.formatFileSize(data.size)}${eolNote}`;
|
||||
}
|
||||
},
|
||||
|
||||
_onFilePreviewEditInput() {
|
||||
const edit = this.filePreviewEdit;
|
||||
if (!edit) return;
|
||||
const textarea = this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor');
|
||||
if (!textarea) return;
|
||||
edit.dirty = textarea.value !== edit.original;
|
||||
const dirtyEl = this.$('filePreviewDirty');
|
||||
if (dirtyEl) dirtyEl.hidden = !edit.dirty;
|
||||
const saveBtn = this.$('filePreviewSaveBtn');
|
||||
if (saveBtn) saveBtn.disabled = !edit.dirty || edit.saving;
|
||||
},
|
||||
|
||||
cancelFilePreviewEdit() {
|
||||
const edit = this.filePreviewEdit;
|
||||
if (!edit) return;
|
||||
if (edit.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
const { sessionId, filePath } = edit;
|
||||
this._resetFilePreviewEdit();
|
||||
this.openFilePreview(filePath, sessionId);
|
||||
},
|
||||
|
||||
async saveFilePreviewEdit(force = false) {
|
||||
const edit = this.filePreviewEdit;
|
||||
if (!edit || edit.saving) return;
|
||||
const textarea = this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor');
|
||||
if (!textarea) return;
|
||||
|
||||
edit.saving = true;
|
||||
const saveBtn = this.$('filePreviewSaveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
}
|
||||
const restoreSaveState = () => {
|
||||
edit.saving = false;
|
||||
if (saveBtn) saveBtn.textContent = 'Save';
|
||||
this._onFilePreviewEditInput();
|
||||
};
|
||||
|
||||
let result = null;
|
||||
let status = 0;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${edit.sessionId}/file-content`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: edit.filePath,
|
||||
content: textarea.value,
|
||||
baseHash: edit.baseHash,
|
||||
eol: edit.eol ?? undefined, // Zod .optional() rejects null
|
||||
force: force || undefined,
|
||||
}),
|
||||
});
|
||||
status = res.status;
|
||||
result = await res.json().catch(() => null);
|
||||
} catch (err) {
|
||||
restoreSaveState();
|
||||
this.showToast(`Save failed: ${err.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 409 || result?.errorCode === 'CONFLICT') {
|
||||
restoreSaveState();
|
||||
if (
|
||||
confirm(
|
||||
'File changed on disk since you loaded it.\nOK overwrites it with your version; Cancel keeps your draft open.'
|
||||
)
|
||||
) {
|
||||
this.saveFilePreviewEdit(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!result || result.success !== true) {
|
||||
restoreSaveState();
|
||||
this.showToast(`Save failed: ${result?.error || `HTTP ${status}`}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, filePath } = edit;
|
||||
this._resetFilePreviewEdit();
|
||||
this.showToast('Saved', 'success');
|
||||
// Re-open in read mode — re-fetching shows the truth on disk (including the
|
||||
// server-side EOL normalization) rather than trusting the local buffer.
|
||||
this.openFilePreview(filePath, sessionId);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment Cards (detected documents/images)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -3749,8 +3927,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
copyFilePreviewContent() {
|
||||
if (this.filePreviewContent) {
|
||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||
// While editing, copy the live editor buffer (not the stale preview text).
|
||||
const editTextarea = this.filePreviewEdit
|
||||
? this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor')
|
||||
: null;
|
||||
const content = editTextarea ? editTextarea.value : this.filePreviewContent;
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
|
||||
@@ -848,6 +848,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeSessionOptions() {
|
||||
// Commit the field the user was still editing BEFORE editingSessionId is
|
||||
// cleared. The Session Name input saves on blur (and the auto-compact prompt
|
||||
// on change), and every autosave handler bails out on `!this.editingSessionId`.
|
||||
// Hiding the modal blurs the focused input on its own, but that happens after
|
||||
// the id is gone, so Escape / backdrop-click silently dropped what was typed.
|
||||
// (Clicking the X worked only because mousedown blurs the input first.)
|
||||
const modal = document.getElementById('sessionOptionsModal');
|
||||
const focused = document.activeElement;
|
||||
if (focused && modal && modal.contains(focused) && typeof focused.blur === 'function') {
|
||||
focused.blur();
|
||||
}
|
||||
|
||||
this.editingSessionId = null;
|
||||
// Stop run summary auto-refresh if it was running
|
||||
this.stopRunSummaryAutoRefresh();
|
||||
|
||||
+146
-35
@@ -316,6 +316,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
select.dataset.listenerAdded = 'true';
|
||||
}
|
||||
this.setupQuickStartCasePicker();
|
||||
// The phone overview labels rows with their case name, and a case rename or
|
||||
// link does not go through the session-tab renderer.
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to load cases:', err);
|
||||
}
|
||||
@@ -370,7 +373,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderSessionTabsImmediate?.();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
|
||||
async run() {
|
||||
if (this._runInFlight) return;
|
||||
|
||||
@@ -394,6 +397,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'gemini') {
|
||||
return await this.runGemini();
|
||||
}
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
@@ -435,6 +441,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Load history sessions when menu opens
|
||||
if (menu.classList.contains('active')) {
|
||||
this._loadRunModeHistory();
|
||||
this._refreshRunModeAvailability(menu);
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target)) {
|
||||
menu.classList.remove('active');
|
||||
@@ -445,6 +452,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* #201: hides run-mode dropdown entries for CLIs that aren't installed, so
|
||||
* picking one doesn't spawn a session that immediately errors out.
|
||||
*
|
||||
* Shell has no external CLI dependency and is never gated, which is also what
|
||||
* guarantees the menu is never empty. Scoped to `menu` rather than the document:
|
||||
* `.run-mode-option` is also the class the saved-dashboard rows and the history
|
||||
* rows use, and a bare querySelector would find whichever came first in the DOM.
|
||||
*
|
||||
* Antigravity is in this list even though #201 predates it — it is a run mode
|
||||
* like the rest, and `agy` is the LEAST likely of the five to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
const container = document.getElementById('runModeHistory');
|
||||
if (!container) return;
|
||||
@@ -503,7 +529,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -576,13 +602,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
return startNumber;
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch progress may use the terminal only on the session-less home screen.
|
||||
* When another session is active, mutating the shared xterm would serialize
|
||||
* launch chrome into that session's snapshot during the subsequent switch.
|
||||
*/
|
||||
_beginSessionLaunchStatus(message, ansiColor = '1;32') {
|
||||
const ownsTerminal = !this.activeSessionId;
|
||||
if (ownsTerminal) {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
} else {
|
||||
this.showToast?.(message, 'info');
|
||||
}
|
||||
return ownsTerminal;
|
||||
},
|
||||
|
||||
_appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') {
|
||||
if (!ownsTerminal || this.activeSessionId) return;
|
||||
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
||||
},
|
||||
|
||||
_reportSessionLaunchError(ownsTerminal, message) {
|
||||
if (ownsTerminal && !this.activeSessionId) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`);
|
||||
} else {
|
||||
this.showToast?.(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||
);
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
// before the first async call. The keyboard opens here and stays open through
|
||||
@@ -665,7 +721,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
|
||||
if (remoteIds[0]) {
|
||||
await this.selectSession(remoteIds[0]);
|
||||
this.loadQuickStartCases();
|
||||
@@ -700,7 +756,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`);
|
||||
const createPromises = sessionNames.map(name =>
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
@@ -741,12 +797,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
));
|
||||
|
||||
// Step 3: Start all sessions in parallel (biggest speedup)
|
||||
this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`);
|
||||
await Promise.all(sessionIds.map(id =>
|
||||
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
||||
));
|
||||
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`);
|
||||
|
||||
// Auto-switch to the new session using selectSession (does proper refresh)
|
||||
if (firstSessionId) {
|
||||
@@ -756,7 +812,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -799,9 +855,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||
'1;33'
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the case path
|
||||
@@ -907,7 +964,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -918,9 +975,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
@@ -930,8 +985,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -964,7 +1021,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -975,9 +1032,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
@@ -985,8 +1040,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1060,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -1021,7 +1079,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1032,9 +1090,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
@@ -1042,8 +1098,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1072,7 +1130,58 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async runAntigravity() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'antigravity',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Antigravity');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1088,7 +1197,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.editingSessionId = sessionId;
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -1118,7 +1227,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
@@ -2538,6 +2647,8 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,6 +312,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// Entrance animations. Deliberately NOT part of the settings payload: the
|
||||
// styles persist to their own localStorage keys via setAnimTheme(), which
|
||||
// keeps them per-device without touching the .strict() SettingsUpdateSchema.
|
||||
this._syncEntranceAnimSetting?.();
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
// the toggle there so it can't promise something that won't apply).
|
||||
document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
|
||||
@@ -327,6 +331,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 430px, so the row is
|
||||
// hidden elsewhere rather than offering a toggle that changes nothing.
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
|
||||
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
|
||||
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
|
||||
const phoneSection = document.getElementById('appSettingsPhoneSection');
|
||||
if (phoneSection) phoneSection.style.display = phoneOnly;
|
||||
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
|
||||
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
|
||||
// in the template, so an unchecked box and a hidden button stay consistent).
|
||||
@@ -361,9 +373,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
claudeModeSelect.onchange = () => {
|
||||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||||
};
|
||||
// Codex CLI settings
|
||||
// Codex CLI settings. The inputs are always populated (and always read back
|
||||
// by saveAppSettings), even when the tab is hidden below, so a user without
|
||||
// codex installed can never silently wipe the codex prefs of an instance
|
||||
// that does have it.
|
||||
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
|
||||
settings.codexDangerouslyBypassApprovals ?? false;
|
||||
document.getElementById('appSettingsCodexAnimations').checked =
|
||||
settings.codexAnimationsEnabled ?? false;
|
||||
this._applyCodexSettingsVisibility();
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
@@ -410,7 +428,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
|
||||
// Response complete (stop)
|
||||
const stopPref = eventTypes.stop || {};
|
||||
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
|
||||
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false;
|
||||
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
|
||||
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
|
||||
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
|
||||
@@ -473,6 +491,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeFocusTrap.activate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the App Settings "Codex CLI" tab only on instances where the codex
|
||||
* binary actually resolves. Both settings on it (approval bypass, animated
|
||||
* status effects) are passed to `codex` at launch, so on a box without codex
|
||||
* the tab is a promise nothing can keep.
|
||||
*
|
||||
* Availability comes from the injected `window.__codemanCliAvailable`, shared
|
||||
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
|
||||
* in and back out. Only the tab BUTTON is toggled: the panel already carries
|
||||
* `.modal-tab-content.hidden` unless it is the selected tab, and
|
||||
* openAppSettings() always reopens on Display, so an unreachable button is
|
||||
* enough to keep the panel unreachable.
|
||||
*
|
||||
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
|
||||
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
|
||||
* and are still saved), whereas hiding a run button would leave a working
|
||||
* install with nothing to click.
|
||||
*/
|
||||
_applyCodexSettingsVisibility() {
|
||||
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
|
||||
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
|
||||
},
|
||||
|
||||
switchSettingsTab(tabName) {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
// Toggle active class on tab buttons
|
||||
@@ -682,6 +723,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updatePollTimer = setInterval(poll, 1500);
|
||||
},
|
||||
|
||||
/**
|
||||
* Is `tool` installed on the server? Reads `window.__codemanCliAvailable`,
|
||||
* injected by renderIndexHtml (see the comment there for why this is injected
|
||||
* rather than fetched per surface).
|
||||
*
|
||||
* Unknown reads as AVAILABLE. A missing flag means the page was rendered by a
|
||||
* build that predates the injection, or by a solo popup: hiding every run
|
||||
* button on a doubt would leave nothing to click, and the pre-existing failure
|
||||
* mode for a genuinely missing CLI is just an error toast.
|
||||
*/
|
||||
isCliAvailable(tool) {
|
||||
const flags = window.__codemanCliAvailable;
|
||||
if (!flags || typeof flags !== 'object') return true;
|
||||
return flags[tool] !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* #200: show a welcome-screen button only where the thing it launches exists.
|
||||
* The markup ships them hidden, so an old cached page can never flash a button
|
||||
* for a tool this server does not have.
|
||||
*/
|
||||
applyWelcomeCliVisibility() {
|
||||
const buttons = [
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
['welcomeTunnelBtn', 'cloudflared'],
|
||||
];
|
||||
for (const [id, tool] of buttons) {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async loadTunnelStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
@@ -1449,6 +1526,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
@@ -1469,6 +1547,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
@@ -1589,7 +1668,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
audio: document.getElementById('eventSubagentAudio').checked,
|
||||
},
|
||||
},
|
||||
_version: 4,
|
||||
_version: 5,
|
||||
};
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.preferences = notifPrefsToSave;
|
||||
@@ -1612,6 +1691,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// The phone home surface (overview vs welcome) may have just been toggled.
|
||||
// Only re-decide while a home screen is actually up.
|
||||
if (!this.activeSessionId) this.showWelcome();
|
||||
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
@@ -1642,6 +1725,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -1810,6 +1895,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: false,
|
||||
// Phone home screen: the C logo opens the session overview instead of the
|
||||
// welcome screen. ON by default here, and the escape hatch if it ever
|
||||
// misbehaves on a device (the gate treats only an explicit false as off).
|
||||
mobileOverviewEnabled: true,
|
||||
// Remote auto-reconnect (COD-108) — on by default
|
||||
remoteAutoReconnect: true,
|
||||
// Input
|
||||
@@ -2265,6 +2354,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'mobileOverviewEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||
@@ -2295,7 +2385,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (notificationPreferences && this.notificationManager) {
|
||||
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
|
||||
if (!localNotifPrefs) {
|
||||
this.notificationManager.preferences = notificationPreferences;
|
||||
this.notificationManager.preferences =
|
||||
this.notificationManager.normalizePreferences(notificationPreferences);
|
||||
this.notificationManager.savePreferences();
|
||||
}
|
||||
}
|
||||
|
||||
+770
-1
@@ -328,7 +328,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.search-filter-chip.active,
|
||||
.search-badge-session,
|
||||
.history-view-all-btn,
|
||||
.session-tab .tab-mode.gemini
|
||||
.session-tab .tab-mode.gemini,
|
||||
.session-tab .tab-mode.antigravity
|
||||
) {
|
||||
color: var(--accent-d);
|
||||
}
|
||||
@@ -638,6 +639,674 @@ body {
|
||||
100% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
|
||||
}
|
||||
|
||||
/* ── Tab entrance animations (tab-entrance.js) ─────────────────────────────
|
||||
Style is picked by html[data-tab-anim]; the stagger arrives as an inline
|
||||
--tab-enter-delay per tab (negative when an entrance is resuming after a
|
||||
re-render). Duration is scaled by --anim-enter-scale so one slider retimes
|
||||
every style.
|
||||
|
||||
Colour/glow effects live on ::before, never on the tab itself: .session-tab.active
|
||||
sets background/border/box-shadow with !important (and a newly created session is
|
||||
normally the active one), and !important beats an animation. Transform and opacity
|
||||
are unaffected, so the tab keeps those. */
|
||||
|
||||
.session-tab.tab-enter {
|
||||
animation-delay: var(--tab-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.session-tab.tab-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-delay: var(--tab-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* Slide, drifts in from the right and settles. */
|
||||
html[data-tab-anim="slide"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-slide;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes tab-enter-slide {
|
||||
from { opacity: 0; transform: translate3d(24px, 3px, 0) scale(0.96); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Pop, springs past full size, then settles back. */
|
||||
html[data-tab-anim="pop"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-pop;
|
||||
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-pop {
|
||||
0% { opacity: 0; transform: scale(0.4); }
|
||||
55% { opacity: 1; transform: scale(1.13); }
|
||||
76% { opacity: 1; transform: scale(0.965); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* CRT, snaps open as a hot line, then unfolds vertically. */
|
||||
html[data-tab-anim="crt"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-crt;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-tab-anim="crt"] .session-tab.tab-enter::before {
|
||||
animation-name: tab-enter-crt-flash;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-crt {
|
||||
0% { opacity: 0; transform: scale3d(0.04, 0.09, 1); }
|
||||
20% { opacity: 1; transform: scale3d(1, 0.09, 1); }
|
||||
56% { opacity: 1; transform: scale3d(1, 1.14, 1); }
|
||||
78% { opacity: 1; transform: scale3d(1, 0.95, 1); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes tab-enter-crt-flash {
|
||||
0% { opacity: 1; background: rgba(205, 255, 225, 0.95); box-shadow: 0 0 26px 8px rgba(0, 255, 102, 0.6); }
|
||||
20% { opacity: 0.9; background: rgba(150, 255, 195, 0.7); box-shadow: 0 0 20px 6px rgba(0, 255, 102, 0.45); }
|
||||
60% { opacity: 0.32; background: rgba(0, 255, 102, 0.14); box-shadow: 0 0 12px 2px rgba(0, 255, 102, 0.22); }
|
||||
100% { opacity: 0; background: transparent; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Unroll, the strip makes room and the tab widens into it. */
|
||||
html[data-tab-anim="unroll"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-unroll;
|
||||
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-unroll {
|
||||
0% { max-width: 0; opacity: 0; padding-left: 0; padding-right: 0; transform: translateY(3px); }
|
||||
30% { opacity: 1; }
|
||||
100% { max-width: 900px; opacity: 1; padding-left: 0.6rem; padding-right: 0.6rem; transform: none; }
|
||||
}
|
||||
|
||||
/* Boot, flickers on under a green scan sweep, like a display warming up. */
|
||||
html[data-tab-anim="boot"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-boot;
|
||||
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
html[data-tab-anim="boot"] .session-tab.tab-enter::before {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(0, 255, 102, 0.06) 34%,
|
||||
rgba(200, 255, 220, 0.5) 50%,
|
||||
rgba(0, 255, 102, 0.06) 66%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 280% 100%;
|
||||
animation-name: tab-enter-boot-scan;
|
||||
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-boot {
|
||||
0% { opacity: 0; transform: translate3d(0, 5px, 0); }
|
||||
8% { opacity: 0.85; }
|
||||
15% { opacity: 0.12; }
|
||||
23% { opacity: 1; }
|
||||
31% { opacity: 0.35; }
|
||||
42% { opacity: 1; transform: none; }
|
||||
55% { opacity: 0.72; }
|
||||
66% { opacity: 1; }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes tab-enter-boot-scan {
|
||||
0% { opacity: 0; background-position: 150% 0; }
|
||||
12% { opacity: 1; }
|
||||
86% { opacity: 1; }
|
||||
100% { opacity: 0; background-position: -50% 0; }
|
||||
}
|
||||
|
||||
/* Flip, drops in as a card hinged on its top edge. */
|
||||
html[data-tab-anim="flip"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-flip;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-flip {
|
||||
0% { opacity: 0; transform: perspective(700px) rotateX(-92deg); }
|
||||
55% { opacity: 1; transform: perspective(700px) rotateX(13deg); }
|
||||
78% { opacity: 1; transform: perspective(700px) rotateX(-5deg); }
|
||||
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
|
||||
}
|
||||
|
||||
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
|
||||
|
||||
.anim-lab {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 3100;
|
||||
width: 300px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
background: rgba(12, 16, 14, 0.96);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||
color: var(--text);
|
||||
font-size: 0.72rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.anim-lab-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.anim-lab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-close:hover { color: var(--text); }
|
||||
|
||||
.anim-lab-themes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.anim-lab-themes button {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-themes button:hover { background: rgba(34, 197, 94, 0.16); color: var(--text); }
|
||||
|
||||
.anim-lab-themes button.selected {
|
||||
border-color: rgba(0, 255, 102, 0.7);
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anim-lab-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.anim-lab-scroll::-webkit-scrollbar { width: 5px; }
|
||||
.anim-lab-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
.anim-lab-group { margin-bottom: 10px; }
|
||||
|
||||
.anim-lab-group-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.anim-lab-style {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
margin-bottom: 3px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-style:hover { background: rgba(34, 197, 94, 0.09); color: var(--text); }
|
||||
|
||||
.anim-lab-style.selected {
|
||||
border-color: rgba(0, 255, 102, 0.6);
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anim-lab-style strong { font-size: 0.73rem; font-weight: 600; }
|
||||
.anim-lab-style em { font-style: normal; font-size: 0.64rem; opacity: 0.7; }
|
||||
|
||||
.anim-lab-range {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
|
||||
|
||||
.anim-lab-demo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.anim-lab-demo button {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-demo button:hover { background: rgba(34, 197, 94, 0.16); border-color: rgba(0, 255, 102, 0.5); }
|
||||
|
||||
.anim-lab-hint { margin: 8px 0 0; font-size: 0.64rem; opacity: 0.55; }
|
||||
|
||||
/* ── Window entrance animations ────────────────────────────────────────────
|
||||
Applied to a window already sitting at its resting position. The `fly` style
|
||||
is NOT here: it is the pre-existing JS transition in subagent-windows.js that
|
||||
flies the window out of its parent tab, and it skips this class entirely. */
|
||||
|
||||
.subagent-window.win-enter,
|
||||
.ultracode-window.win-enter {
|
||||
animation-delay: var(--win-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.subagent-window.win-enter::before,
|
||||
.ultracode-window.win-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-delay: var(--win-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* CRT, bursts open as a hot line, then unfolds vertically. */
|
||||
html[data-win-anim="crt"] .subagent-window.win-enter,
|
||||
html[data-win-anim="crt"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-crt;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-win-anim="crt"] .subagent-window.win-enter::before,
|
||||
html[data-win-anim="crt"] .ultracode-window.win-enter::before {
|
||||
animation-name: win-enter-crt-flash;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes win-enter-crt {
|
||||
0% { opacity: 0; transform: scale3d(0.55, 0.008, 1); }
|
||||
22% { opacity: 1; transform: scale3d(1, 0.01, 1); }
|
||||
58% { opacity: 1; transform: scale3d(1, 1.05, 1); }
|
||||
80% { opacity: 1; transform: scale3d(1, 0.98, 1); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes win-enter-crt-flash {
|
||||
0% { opacity: 1; background: rgba(205, 255, 225, 0.9); box-shadow: 0 0 40px 14px rgba(0, 255, 102, 0.55); }
|
||||
22% { opacity: 0.8; background: rgba(120, 255, 180, 0.5); box-shadow: 0 0 30px 10px rgba(0, 255, 102, 0.4); }
|
||||
60% { opacity: 0.25; background: rgba(0, 255, 102, 0.1); box-shadow: 0 0 14px 3px rgba(0, 255, 102, 0.18); }
|
||||
100% { opacity: 0; background: transparent; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Materialize, resolves out of a blur with a short glitch. */
|
||||
html[data-win-anim="materialize"] .subagent-window.win-enter,
|
||||
html[data-win-anim="materialize"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-materialize;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes win-enter-materialize {
|
||||
0% { opacity: 0; transform: scale(0.94); filter: blur(16px) brightness(1.7); }
|
||||
35% { opacity: 0.85; filter: blur(6px) brightness(1.3); }
|
||||
45% { opacity: 0.4; }
|
||||
55% { opacity: 0.95; filter: blur(3px) brightness(1.15); }
|
||||
70% { opacity: 0.75; }
|
||||
100% { opacity: 1; transform: none; filter: none; }
|
||||
}
|
||||
|
||||
/* Unfold, hinges down from the top edge. */
|
||||
html[data-win-anim="unfold"] .subagent-window.win-enter,
|
||||
html[data-win-anim="unfold"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-unfold;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
@keyframes win-enter-unfold {
|
||||
0% { opacity: 0; transform: perspective(1600px) rotateX(-88deg); }
|
||||
55% { opacity: 1; transform: perspective(1600px) rotateX(9deg); }
|
||||
80% { opacity: 1; transform: perspective(1600px) rotateX(-3deg); }
|
||||
100% { opacity: 1; transform: perspective(1600px) rotateX(0deg); }
|
||||
}
|
||||
|
||||
/* Beam down, holds still (so its connection line can draw toward a stable rect),
|
||||
then materializes out of a green wash. NO transform: a transformed window
|
||||
reports a transformed rect and the line would aim at the wrong place. */
|
||||
html[data-win-anim="beam"] .subagent-window.win-enter,
|
||||
html[data-win-anim="beam"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-beam;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
html[data-win-anim="beam"] .subagent-window.win-enter::before,
|
||||
html[data-win-anim="beam"] .ultracode-window.win-enter::before {
|
||||
animation-name: win-enter-beam-wash;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes win-enter-beam {
|
||||
0% { opacity: 0; filter: brightness(2.6) saturate(0.4); }
|
||||
20% { opacity: 0.55; }
|
||||
32% { opacity: 0.2; }
|
||||
48% { opacity: 0.9; filter: brightness(1.5) saturate(0.8); }
|
||||
100% { opacity: 1; filter: none; }
|
||||
}
|
||||
|
||||
@keyframes win-enter-beam-wash {
|
||||
0% { opacity: 0.9; background: linear-gradient(180deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0)); }
|
||||
45% { opacity: 0.5; background: linear-gradient(180deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0)); }
|
||||
100% { opacity: 0; background: transparent; }
|
||||
}
|
||||
|
||||
/* Pop, springs open from the centre. */
|
||||
html[data-win-anim="pop"] .subagent-window.win-enter,
|
||||
html[data-win-anim="pop"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-pop;
|
||||
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes win-enter-pop {
|
||||
0% { opacity: 0; transform: scale(0.55); }
|
||||
58% { opacity: 1; transform: scale(1.045); }
|
||||
80% { opacity: 1; transform: scale(0.99); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Main terminal pane entrance animations ────────────────────────────────
|
||||
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
|
||||
from getComputedStyle(parent).width/height, the untransformed layout box -
|
||||
so these are invisible to it, but animating width/height/padding here would
|
||||
resize the PTY mid-animation. Colour washes go on ::before, never as a
|
||||
`filter` on the container: that would blur a full-screen WebGL canvas every
|
||||
frame. `will-change` is deliberately not set, the base rule needs its
|
||||
`will-change: contents` for terminal compositing. */
|
||||
|
||||
.terminal-container.term-enter {
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.terminal-container.term-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* CRT, power-on: a hot line that expands to full height. */
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-crt;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
||||
animation-name: term-enter-crt-flash;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-crt {
|
||||
0% { opacity: 0; transform: scale3d(0.6, 0.004, 1); }
|
||||
20% { opacity: 1; transform: scale3d(1, 0.006, 1); }
|
||||
58% { opacity: 1; transform: scale3d(1, 1.03, 1); }
|
||||
80% { opacity: 1; transform: scale3d(1, 0.99, 1); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes term-enter-crt-flash {
|
||||
0% { opacity: 1; background: rgba(210, 255, 230, 0.85); }
|
||||
20% { opacity: 0.7; background: rgba(120, 255, 180, 0.4); }
|
||||
60% { opacity: 0.2; background: rgba(0, 255, 102, 0.08); }
|
||||
100% { opacity: 0; background: transparent; }
|
||||
}
|
||||
|
||||
/* Boot, flickers on under a green scan sweep. */
|
||||
html[data-term-anim="boot"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-boot;
|
||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
html[data-term-anim="boot"] .terminal-container.term-enter::before {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(0, 255, 102, 0.05) 40%,
|
||||
rgba(200, 255, 220, 0.28) 50%,
|
||||
rgba(0, 255, 102, 0.05) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
animation-name: term-enter-boot-scan;
|
||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-boot {
|
||||
0% { opacity: 0; transform: scale(0.995); }
|
||||
8% { opacity: 0.85; }
|
||||
15% { opacity: 0.1; }
|
||||
24% { opacity: 1; }
|
||||
33% { opacity: 0.35; }
|
||||
45% { opacity: 1; transform: none; }
|
||||
58% { opacity: 0.7; }
|
||||
70% { opacity: 1; }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes term-enter-boot-scan {
|
||||
0% { opacity: 0; background-position: 0 -150%; }
|
||||
12% { opacity: 1; }
|
||||
86% { opacity: 1; }
|
||||
100% { opacity: 0; background-position: 0 150%; }
|
||||
}
|
||||
|
||||
/* Wipe, reveals top-to-bottom behind a bright edge. */
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-wipe;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
|
||||
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
|
||||
animation-name: term-enter-wipe-edge;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes term-enter-wipe {
|
||||
0% { opacity: 1; clip-path: inset(0 0 100% 0); }
|
||||
100% { opacity: 1; clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@keyframes term-enter-wipe-edge {
|
||||
0% { opacity: 0.9; clip-path: inset(0 0 100% 0); }
|
||||
80% { opacity: 0.45; }
|
||||
100% { opacity: 0; clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
/* Slide up, rises into place from below. */
|
||||
html[data-term-anim="slide"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-slide;
|
||||
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes term-enter-slide {
|
||||
0% { opacity: 0; transform: translate3d(0, 34px, 0); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Fade, quiet, with a touch of scale. */
|
||||
html[data-term-anim="fade"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-fade;
|
||||
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-fade {
|
||||
0% { opacity: 0; transform: scale(0.985); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── Connection-line entrance animations ───────────────────────────────────
|
||||
`--line-len` is the measured path length, stamped inline by
|
||||
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
|
||||
resuming after the SVG was rebuilt underneath it. */
|
||||
|
||||
.connection-line.line-enter {
|
||||
animation-delay: var(--line-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* Draw, the line paints itself from the tab down to the window. Overrides the
|
||||
resting 5 3 dash to a single full-length dash for the duration. */
|
||||
html[data-line-anim="draw"] .connection-line.line-enter {
|
||||
stroke-dasharray: var(--line-len);
|
||||
animation-name: line-enter-draw;
|
||||
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes line-enter-draw {
|
||||
from { stroke-dashoffset: var(--line-len); opacity: 1; }
|
||||
to { stroke-dashoffset: 0; opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Fade, plain opacity ramp, dashes intact. */
|
||||
html[data-line-anim="fade"] .connection-line.line-enter {
|
||||
animation-name: line-enter-fade;
|
||||
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes line-enter-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Packet, the base line fades in and a separate bright dash rides down it. The
|
||||
packet is its own cloned path so the base line keeps its dashed look. */
|
||||
html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
animation-name: line-enter-fade;
|
||||
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.connection-line-packet {
|
||||
fill: none;
|
||||
stroke: #cdfbe3;
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
stroke-dasharray: 20px 100000px;
|
||||
filter: drop-shadow(0 0 4px rgba(0, 255, 102, 0.95)) drop-shadow(0 0 10px rgba(59, 130, 246, 0.7));
|
||||
animation-name: line-enter-packet;
|
||||
animation-duration: calc(700ms * var(--anim-enter-scale, 1));
|
||||
animation-delay: var(--line-enter-delay, 0ms);
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes line-enter-packet {
|
||||
0% { stroke-dashoffset: 20px; opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
|
||||
}
|
||||
|
||||
.anim-lab-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: -4px 0 12px;
|
||||
padding: 0 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-check input { accent-color: #00ff66; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-tab.tab-enter,
|
||||
.session-tab.tab-enter::before,
|
||||
.subagent-window.win-enter,
|
||||
.subagent-window.win-enter::before,
|
||||
.ultracode-window.win-enter,
|
||||
.ultracode-window.win-enter::before,
|
||||
.terminal-container.term-enter,
|
||||
.terminal-container.term-enter::before,
|
||||
.connection-line.line-enter,
|
||||
.connection-line-packet {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.session-tab .tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -1447,6 +2116,11 @@ body.solo-mode .btn-lifecycle-log {
|
||||
color: #8ab4f8;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.antigravity {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
@@ -3531,6 +4205,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #eff6ff;
|
||||
}
|
||||
|
||||
/* Antigravity mode colors */
|
||||
.btn-toolbar.btn-run.mode-antigravity,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity {
|
||||
background: linear-gradient(135deg, #0b2b33 0%, #0e7490 55%, #0891b2 100%);
|
||||
border-color: rgba(34, 211, 238, 0.5);
|
||||
color: #cffafe;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-antigravity:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity:hover {
|
||||
background: linear-gradient(135deg, #124450 0%, #0891b2 55%, #06b6d4 100%);
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35), 0 2px 8px rgba(8, 145, 178, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(103, 232, 249, 0.6);
|
||||
color: #ecfeff;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.run-mode-menu {
|
||||
display: none;
|
||||
@@ -3590,6 +4280,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
.run-mode-dot.opencode { background: #10b981; }
|
||||
.run-mode-dot.codex { background: #a855f7; }
|
||||
.run-mode-dot.gemini { background: #8ab4f8; }
|
||||
.run-mode-dot.antigravity { background: #22d3ee; }
|
||||
.run-mode-dot.shell { background: #94a3b8; }
|
||||
|
||||
/* Phone-only Enter button (see index.html). Hidden by default at every width;
|
||||
@@ -8739,6 +9430,84 @@ kbd {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- File Viewer edit mode (issue #212) ---- */
|
||||
|
||||
.file-preview-body textarea.file-preview-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.file-preview-editbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview-editbar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-preview-editbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-preview-dirty {
|
||||
font-size: 0.7rem;
|
||||
color: var(--warning, #e5c07b);
|
||||
}
|
||||
|
||||
.file-preview-dirty::before {
|
||||
content: '\25CF ';
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn--save {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn--save:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn--save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ========== Log Viewer Windows (Floating) ========== */
|
||||
|
||||
.log-viewer-window {
|
||||
|
||||
@@ -465,6 +465,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||
}
|
||||
|
||||
// Every path above was just created from scratch, so any line entrance in
|
||||
// flight has to be re-attached here (resumed via a negative animation-delay).
|
||||
this._applyLineEntrances?.(svg);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -729,12 +733,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Only the `fly` entrance style parks the window on its parent tab; every
|
||||
// CSS-animated style (entrance-animations.js) needs it to start at its
|
||||
// resting position, or the animation would play at the wrong place.
|
||||
const flyFromTab = !!parentTab && !isMobile && this.windowEntranceFliesFromTab?.() !== false;
|
||||
|
||||
// If we have a parent tab, start window at tab position for spawn animation
|
||||
if (isMobile) {
|
||||
// Mobile: position using top (keyboard-aware positioning calculated above)
|
||||
win.style.top = `${finalY}px`;
|
||||
win.style.bottom = 'auto';
|
||||
} else if (parentTab) {
|
||||
} else if (flyFromTab) {
|
||||
const tabRect = parentTab.getBoundingClientRect();
|
||||
win.style.left = `${tabRect.left}px`;
|
||||
win.style.top = `${tabRect.bottom}px`;
|
||||
@@ -812,7 +821,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
|
||||
|
||||
// Animate to final position if spawning from tab (desktop only)
|
||||
if (parentTab && !isMobile) {
|
||||
if (flyFromTab) {
|
||||
requestAnimationFrame(() => {
|
||||
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
win.style.left = `${finalX}px`;
|
||||
@@ -824,11 +833,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(() => {
|
||||
win.style.transition = '';
|
||||
win.classList.remove('spawning');
|
||||
// The line only becomes meaningful once the window has landed, so its
|
||||
// draw-in starts here rather than at spawn.
|
||||
this.markConnectionLineEntering?.(agentId);
|
||||
this.updateConnectionLines();
|
||||
}, 400);
|
||||
});
|
||||
} else {
|
||||
// No animation (mobile uses CSS positioning), just update connection lines
|
||||
// CSS-animated entrance styles (and mobile) run in place. The window is
|
||||
// already at its resting position, so the connection line can be drawn
|
||||
// against a correct rect right away and animate alongside it.
|
||||
//
|
||||
// Skipped when the window spawns hidden (its agent belongs to a background
|
||||
// tab): a display:none element never runs its animation, so `animationend`
|
||||
// would never fire and the entrance class plus its inline custom property
|
||||
// would stick to the window forever. Nothing is visible to animate anyway,
|
||||
// and revealing it later is a tab switch, not a spawn.
|
||||
if (!shouldHide) {
|
||||
this.applyWindowEntrance?.(win);
|
||||
this.markConnectionLineEntering?.(agentId);
|
||||
}
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
||||
// ^C. With NO selection the branch must fall through (return true, and no
|
||||
// preventDefault) or the interrupt key is lost, which is the whole reason
|
||||
// the selection check runs before any registry dispatch. Ctrl+Shift+C is
|
||||
// the explicit copy chord and never falls through: an "explicit copy" that
|
||||
// interrupts a running agent because the selection happened to be empty is
|
||||
// a footgun with no upside.
|
||||
// NOTE: returning false does NOT cancel the event (xterm's _keyDown calls
|
||||
// this handler before its own cancel()), so preventDefault is explicit:
|
||||
// without it the browser runs its native copy on top of ours.
|
||||
if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||
if (selection) {
|
||||
ev.preventDefault();
|
||||
void this.copyTerminalSelection(selection);
|
||||
return false;
|
||||
}
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||
// Route through our paste trap which handles both images and text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
@@ -1178,10 +1202,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
showWelcome() {
|
||||
// Phones get the session overview instead of the welcome screen: on a small
|
||||
// screen "which session is blocked on me" beats "how do I start one". The
|
||||
// gate lives in mobile-overview.js; every other device falls through
|
||||
// unchanged. Both surfaces are toggled here so a breakpoint change (rotate,
|
||||
// unfold) swaps cleanly instead of showing both.
|
||||
if (this.shouldUseMobileOverview?.()) {
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) overlay.classList.remove('visible');
|
||||
this.showMobileOverview();
|
||||
this._updateCjkInputState?.();
|
||||
return;
|
||||
}
|
||||
this.hideMobileOverview?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('visible');
|
||||
this.loadTunnelStatus();
|
||||
this.applyWelcomeCliVisibility();
|
||||
this.loadHistorySessions();
|
||||
this.initSearchPanel();
|
||||
}
|
||||
@@ -1191,6 +1229,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -2597,6 +2636,46 @@ Object.assign(CodemanApp.prototype, {
|
||||
// intentionally empty
|
||||
},
|
||||
|
||||
// Registry-aware gate for the smart-copy chord (#211). Mirrors
|
||||
// shouldOpenCommandPaletteFromShortcut(): honors a rebound or disabled
|
||||
// 'copy-selection' entry, and falls back to the default chord when the
|
||||
// registry isn't available (isolated test harnesses).
|
||||
// Returning true only means "this chord asked to copy", the CALLER decides
|
||||
// what happens when there is no selection, so the interrupt stays intact.
|
||||
shouldCopyTerminalSelectionFromShortcut(ev) {
|
||||
// The custom key handler also runs for keypress/keyup; only keydown decides.
|
||||
if (!ev || ev.type !== 'keydown') return false;
|
||||
// Hot path: every dispatchable chord needs Ctrl/Cmd/Alt, so plain typing
|
||||
// exits before any registry work.
|
||||
if (!ev.ctrlKey && !ev.metaKey && !ev.altKey) return false;
|
||||
const registryAvailable =
|
||||
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
|
||||
const entry = registryAvailable ? this.getShortcutRegistry().find((s) => s.id === 'copy-selection') : null;
|
||||
if (entry) return !entry.disabled && this.matchesShortcutEvent(ev, entry);
|
||||
return !ev.altKey && (ev.key || '').toLowerCase() === 'c';
|
||||
},
|
||||
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
if (!selection) return false;
|
||||
const ok = await this._copyText(selection);
|
||||
if (ok) {
|
||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||
// drops the selection on any keypress, so this matches existing feel).
|
||||
this.terminal.clearSelection?.();
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
} else {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
}
|
||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||
// is the CJK-aware focus router, not xterm's raw focus().
|
||||
this.terminal.focus();
|
||||
return ok;
|
||||
},
|
||||
|
||||
async copyTerminal() {
|
||||
try {
|
||||
const buffer = this.terminal.buffer.active;
|
||||
|
||||
@@ -210,6 +210,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.body.appendChild(win);
|
||||
// Drop the spawn class on the next frame so the transition runs.
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
// Then hand over to the chosen entrance style (no-op for `fly`/`off`, which
|
||||
// leave the small scale-in transition above as the whole animation).
|
||||
this.applyWindowEntrance?.(win);
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
@@ -586,6 +589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
document.body.appendChild(win);
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
this.applyWindowEntrance?.(win);
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* @fileoverview File browser and streaming routes.
|
||||
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
||||
* Provides directory listing, file content preview, raw file serving, tail
|
||||
* streaming, and the File Viewer edit-mode write path (edit=1 read +
|
||||
* PUT /api/sessions/:id/file-content; policy in src/config/file-editing.ts,
|
||||
* design in docs/file-viewer-edit-plan.md).
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import type {
|
||||
ApiResponse,
|
||||
@@ -14,6 +18,7 @@ import type {
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
FileWriteData,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
@@ -44,7 +49,14 @@ import type { SessionAttachmentHistoryItem, SessionState } from '../../types/ses
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
|
||||
import {
|
||||
MAX_EDITABLE_BYTES,
|
||||
applyEol,
|
||||
detectEol,
|
||||
isDeniedEditRelativePath,
|
||||
isEditableFileName,
|
||||
} from '../../config/file-editing.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -453,6 +465,71 @@ function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
|
||||
// ===== File Viewer edit mode (issue #212) =====
|
||||
// Policy lives in src/config/file-editing.ts; design in docs/file-viewer-edit-plan.md.
|
||||
|
||||
function sha256Hex(buf: Buffer): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/** NUL byte in the first 8KB — same binary signal the plain read path uses. */
|
||||
function sniffsBinary(buf: Buffer): boolean {
|
||||
const sniffLength = Math.min(buf.length, 8192);
|
||||
for (let i = 0; i < sniffLength; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured-throw variant for the edit read/write paths. Identical mechanics to
|
||||
* throwFilesystemPickerError (rendered by the central route error handler both
|
||||
* in prod and in the app.inject() test harness); a separate name only so edit
|
||||
* failures grep distinctly.
|
||||
*/
|
||||
function throwFileEditError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode,
|
||||
body: createErrorResponse(code, message),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a resolved workspace file for edit-mode read/write. Throws a structured
|
||||
* error when the file may not be edited; returns void when it may. Order
|
||||
* matters for the message a user sees: confinement (the caller's 404) →
|
||||
* sensitive/blocked (403) → .git (403) → extension allowlist (400).
|
||||
*/
|
||||
function assertEditableTarget(resolvedPath: string, relativePath: string, blockedTrees: readonly string[]): void {
|
||||
if (isSensitivePath(resolvedPath) || isBlockedAttachmentPath(resolvedPath, blockedTrees)) {
|
||||
throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Editing this file is blocked');
|
||||
}
|
||||
if (isDeniedEditRelativePath(relativePath)) {
|
||||
throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Files under .git cannot be edited');
|
||||
}
|
||||
if (!isEditableFileName(pathBasename(resolvedPath))) {
|
||||
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'This file type is not editable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a candidate edit buffer, refusing binary and non-UTF-8 content. The
|
||||
* round-trip compare is what protects against silent corruption: decoding
|
||||
* latin-1 (or any non-UTF-8) bytes yields U+FFFD replacements, and writing
|
||||
* those back would destroy the original bytes. A UTF-8 BOM round-trips and is
|
||||
* deliberately preserved.
|
||||
*/
|
||||
function decodeEditableText(buf: Buffer): string {
|
||||
if (sniffsBinary(buf)) {
|
||||
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Binary files cannot be edited');
|
||||
}
|
||||
const text = buf.toString('utf8');
|
||||
if (!Buffer.from(text, 'utf8').equals(buf)) {
|
||||
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only UTF-8 text files can be edited');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
@@ -864,7 +941,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Get file content for preview (File Browser)
|
||||
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
||||
const {
|
||||
path: filePath,
|
||||
lines,
|
||||
raw,
|
||||
edit,
|
||||
} = req.query as { path?: string; lines?: string; raw?: string; edit?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
@@ -876,7 +958,52 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
if (!validated) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
const { resolvedPath, relativePath } = validated;
|
||||
|
||||
// Read-for-edit: never truncated (a truncated buffer must never become an
|
||||
// edit buffer), tighter size cap, full editability gate, and the hash/eol
|
||||
// the client must echo back on PUT. Outside the shared try/catch below so
|
||||
// its structured errors keep their status codes instead of collapsing into
|
||||
// OPERATION_FAILED.
|
||||
if (edit === '1' || edit === 'true') {
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
assertEditableTarget(resolvedPath, relativePath, guard.blockedTrees);
|
||||
|
||||
let editStat;
|
||||
try {
|
||||
editStat = await fs.stat(resolvedPath);
|
||||
} catch {
|
||||
throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
if (!editStat.isFile()) {
|
||||
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be edited');
|
||||
}
|
||||
if (editStat.size > MAX_EDITABLE_BYTES) {
|
||||
throwFileEditError(
|
||||
413,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to edit here (${Math.ceil(editStat.size / 1024)}KB > ${MAX_EDITABLE_BYTES / 1024}KB limit)`
|
||||
);
|
||||
}
|
||||
|
||||
const editBuf = await fs.readFile(resolvedPath);
|
||||
const editText = decodeEditableText(editBuf);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
content: editText,
|
||||
size: editBuf.length,
|
||||
mtimeMs: editStat.mtimeMs,
|
||||
totalLines: editText.split('\n').length,
|
||||
truncated: false,
|
||||
extension: filePath.split('.').pop()?.toLowerCase() || '',
|
||||
editable: true,
|
||||
hash: sha256Hex(editBuf),
|
||||
eol: detectEol(editText),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
@@ -998,6 +1125,19 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const truncatedContent = allLines.length > maxLines;
|
||||
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
||||
|
||||
// Additive edit-mode advertisement: whether an edit=1 re-fetch would
|
||||
// succeed. The UTF-8 round-trip compare is a cheap memcmp and mirrors
|
||||
// decodeEditableText; no hash here — the Edit action re-fetches with
|
||||
// edit=1, which is where the baseHash comes from.
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const editable =
|
||||
isEditableFileName(pathBasename(resolvedPath)) &&
|
||||
!isDeniedEditRelativePath(relativePath) &&
|
||||
!isSensitivePath(resolvedPath) &&
|
||||
!isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) &&
|
||||
stat.size <= MAX_EDITABLE_BYTES &&
|
||||
Buffer.from(content, 'utf8').equals(buf);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1007,6 +1147,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
totalLines: allLines.length,
|
||||
truncated: truncatedContent,
|
||||
extension: ext,
|
||||
editable,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -1014,6 +1155,121 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
});
|
||||
|
||||
// File Viewer edit mode: save a text file back into the session workspace.
|
||||
// Edit-in-place ONLY — there is deliberately no O_CREAT path in this handler,
|
||||
// so it can never create, and it never deletes. Confinement is identical to
|
||||
// the read path (realpath + workspace boundary + ownership via
|
||||
// findSessionOrFail), plus the sensitive-path/attachment-guard blocklists and
|
||||
// the extension allowlist. Concurrency is optimistic: the client echoes the
|
||||
// sha256 it loaded (baseHash) and a mismatch is a 409 unless force is set.
|
||||
// bodyLimit: JSON escaping can expand content up to ~6x (each control char
|
||||
// becomes \uXXXX), so the 512KB content cap needs headroom over Fastify's
|
||||
// 1MB default.
|
||||
app.put(
|
||||
'/api/sessions/:id/file-content',
|
||||
{ bodyLimit: 4 * 1024 * 1024 },
|
||||
async (req): Promise<ApiResponse<FileWriteData>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const body = parseBody(FileWriteSchema, req.body);
|
||||
|
||||
// Exact byte cap — the schema's .max() counts UTF-16 code units and is
|
||||
// only a coarse pre-filter.
|
||||
if (Buffer.byteLength(body.content, 'utf8') > MAX_EDITABLE_BYTES) {
|
||||
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(session.workingDir, body.path);
|
||||
if (!validated) {
|
||||
// Covers missing files, traversal, and symlink escapes alike — a write
|
||||
// target that fails confinement is reported identically to a missing
|
||||
// one, matching the read route.
|
||||
throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
const { resolvedPath, relativePath } = validated;
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
assertEditableTarget(resolvedPath, relativePath, guard.blockedTrees);
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(resolvedPath);
|
||||
} catch {
|
||||
throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be edited');
|
||||
}
|
||||
if (stat.size > MAX_EDITABLE_BYTES) {
|
||||
throwFileEditError(
|
||||
413,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to edit here (${MAX_EDITABLE_BYTES / 1024}KB limit)`
|
||||
);
|
||||
}
|
||||
|
||||
const currentBuf = await fs.readFile(resolvedPath);
|
||||
const currentText = decodeEditableText(currentBuf);
|
||||
const currentHash = sha256Hex(currentBuf);
|
||||
if (currentHash !== body.baseHash && !body.force) {
|
||||
throwFileEditError(
|
||||
409,
|
||||
ApiErrorCode.CONFLICT,
|
||||
'File changed on disk since it was loaded — reload it or overwrite'
|
||||
);
|
||||
}
|
||||
|
||||
// Re-apply the file's original line endings (a <textarea> normalizes to
|
||||
// LF; without this a two-line edit of a CRLF file rewrites every line).
|
||||
const eol = body.eol ?? detectEol(currentText);
|
||||
const outText = applyEol(body.content, eol);
|
||||
const outBuf = Buffer.from(outText, 'utf8');
|
||||
if (outBuf.length > MAX_EDITABLE_BYTES) {
|
||||
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||
}
|
||||
|
||||
// Atomic replace: O_EXCL temp in the same directory, then rename.
|
||||
// 'wx' cannot follow a pre-existing symlink and rename() replaces (not
|
||||
// follows) a symlink in the final component, which closes the
|
||||
// validate-then-write TOCTOU window. fchmod because open()'s mode is
|
||||
// masked by the process umask; fsync so the rename never publishes a
|
||||
// partially-durable file. Trade-off (same as vim's default): the inode
|
||||
// changes, so hardlinks keep the old content.
|
||||
const fileMode = stat.mode & 0o777;
|
||||
const tmpPath = join(
|
||||
dirname(resolvedPath),
|
||||
`.${pathBasename(resolvedPath)}.codeman-tmp-${randomBytes(6).toString('hex')}`
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(tmpPath, 'wx', fileMode);
|
||||
await handle.chmod(fileMode);
|
||||
await handle.writeFile(outBuf);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await fs.rename(tmpPath, resolvedPath);
|
||||
} catch (err) {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throwFileEditError(500, ApiErrorCode.OPERATION_FAILED, `Failed to save file: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
const newStat = await fs.stat(resolvedPath).catch(() => undefined);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: body.path,
|
||||
size: outBuf.length,
|
||||
mtimeMs: newStat?.mtimeMs ?? Date.now(),
|
||||
hash: sha256Hex(outBuf),
|
||||
totalLines: outText.split('\n').length,
|
||||
eol,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Serve raw file content (for images/binary files)
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
+365
-118
@@ -19,6 +19,7 @@ import {
|
||||
type SessionColor,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -282,25 +283,35 @@ export function _resetPasteRateBuckets(): void {
|
||||
|
||||
/**
|
||||
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
|
||||
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
|
||||
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
|
||||
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
|
||||
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`,
|
||||
* Gemini `--approval-mode yolo`, and Antigravity `--dangerously-skip-permissions` disable
|
||||
* the safety classifier the non-granted-user downgrade is meant to keep on, so clamp them
|
||||
* for a non-granted owner. buildGeminiCommand defaults an ABSENT approvalMode to yolo, so
|
||||
* the gemini config must be MATERIALIZED (auto_edit) even when the request sent none.
|
||||
* Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so
|
||||
* only a sent config needs the flag forced off. No-op in single-user mode / for a granted
|
||||
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined
|
||||
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
|
||||
geminiConfig: GeminiConfig | undefined,
|
||||
antigravityConfig: AntigravityConfig | undefined
|
||||
): Promise<{
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
antigravityConfig: AntigravityConfig | undefined;
|
||||
}> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig };
|
||||
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
|
||||
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
if (granted) return { codexConfig, geminiConfig, antigravityConfig };
|
||||
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
||||
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
|
||||
const clampedAntigravity = antigravityConfig
|
||||
? { ...antigravityConfig, dangerouslySkipPermissions: false }
|
||||
: antigravityConfig;
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
@@ -414,6 +425,7 @@ export function registerSessionRoutes(
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.mode !== 'antigravity' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
@@ -480,6 +492,15 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (body.mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
@@ -521,18 +542,20 @@ export function registerSessionRoutes(
|
||||
? body.codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? body.geminiConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'antigravity'
|
||||
? body.antigravityConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig
|
||||
);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const {
|
||||
codexConfig: gatedCodexConfig,
|
||||
geminiConfig: gatedGeminiConfig,
|
||||
antigravityConfig: gatedAntigravityConfig,
|
||||
} = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -547,6 +570,7 @@ export function registerSessionRoutes(
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
@@ -760,11 +784,12 @@ export function registerSessionRoutes(
|
||||
|
||||
try {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||||
// Ralph tracker is not supported for opencode / codex / gemini sessions
|
||||
// Ralph tracker is not supported for opencode / codex / gemini / antigravity sessions
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
session.mode !== 'codex' &&
|
||||
session.mode !== 'gemini' &&
|
||||
session.mode !== 'antigravity' &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -940,80 +965,97 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
|
||||
// Resolves the most recent Claude conversation id for a session's cwd by
|
||||
// tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
|
||||
// to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
|
||||
// that does not rely on project-local hooks (we intentionally don't install
|
||||
// hooks in arbitrary user repos, see the POST /api/sessions comment).
|
||||
// How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and
|
||||
// still be the same submission. Claude appends to history as it accepts the
|
||||
// prompt, so the true gap is milliseconds — this is slack for a loaded box,
|
||||
// not a search radius.
|
||||
const CLAUDE_SUBMIT_MATCH_MS = 10_000;
|
||||
// history.jsonl grows forever; only the tail can hold entries near a submit.
|
||||
const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
// Resolves the Claude conversation id THIS pane is currently on by matching
|
||||
// ~/.claude/history.jsonl (which logs every submitted prompt as
|
||||
// {project, sessionId, timestamp}) against the pane's last Enter. After
|
||||
// `/clear` Claude keeps writing to a new <uuid>.jsonl, and history.jsonl is
|
||||
// the only source-of-truth update that does not rely on project-local hooks
|
||||
// (we intentionally don't install hooks in arbitrary user repos, see the
|
||||
// POST /api/sessions comment).
|
||||
//
|
||||
// Entries from OTHER Codeman sessions in the same cwd are filtered out by
|
||||
// their known claudeSessionIds so concurrent tabs don't shadow each other,
|
||||
// as long as each has had its id resolved at least once.
|
||||
// The pane's own Enter is what makes an entry OURS. `project` alone is not:
|
||||
// a cwd is shared by every other Codeman tab on it, by tabs long since
|
||||
// closed, and by any plain `claude` the user runs in their own terminal —
|
||||
// adopting the newest entry for the cwd pinned the viewer to whichever of
|
||||
// those conversations was typed in last, so the eye showed a stranger's
|
||||
// transcript. With no correlated entry we keep the id we have; a viewer one
|
||||
// turn behind beats a viewer showing someone else's conversation.
|
||||
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
|
||||
async function resolveActiveClaudeSessionIdFromHistory(
|
||||
session: Session,
|
||||
projectsDir: string
|
||||
): Promise<string | null> {
|
||||
const historyPath = join(homedir(), '.claude', 'history.jsonl');
|
||||
const submitAt = session.lastSubmitAt;
|
||||
if (!submitAt) return null; // never typed through Codeman — nothing to credit
|
||||
const cached = claudeHistoryPinCache.get(session.id);
|
||||
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
|
||||
|
||||
// Ids another live pane is already pinned to can never be ours, and every
|
||||
// pane sharing this cwd competes for the entry we are about to claim —
|
||||
// including non-Claude panes, since a shell pane can run `claude` too.
|
||||
const otherClaudeIds = new Set<string>();
|
||||
const otherSubmits: number[] = [];
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
|
||||
otherClaudeIds.add(s.claudeSessionId);
|
||||
}
|
||||
if (s.id === session.id || s.workingDir !== session.workingDir) continue;
|
||||
if (s.claudeSessionId) otherClaudeIds.add(s.claudeSessionId);
|
||||
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
|
||||
}
|
||||
|
||||
let candidateSid: string | null = null;
|
||||
try {
|
||||
const content = await fs.readFile(historyPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as { project?: string; sessionId?: string };
|
||||
if (
|
||||
entry.project === session.workingDir &&
|
||||
typeof entry.sessionId === 'string' &&
|
||||
!otherClaudeIds.has(entry.sessionId)
|
||||
) {
|
||||
candidateSid = entry.sessionId;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!candidateSid || candidateSid === session.id) return candidateSid;
|
||||
const historyPath = join(homedir(), '.claude', 'history.jsonl');
|
||||
const stat = await fs.stat(historyPath).catch(() => null);
|
||||
if (!stat || stat.size === 0) return null;
|
||||
const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
|
||||
if (!tail) return null;
|
||||
|
||||
// Safety: only adopt if the candidate's jsonl is more recently written
|
||||
// than our initial conversation's jsonl. Blocks stale ids inherited from
|
||||
// a prior Codeman session that happened to share this cwd.
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
let best: { sessionId: string; dist: number } | undefined;
|
||||
for (const line of tail.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: { project?: string; sessionId?: string; timestamp?: number };
|
||||
try {
|
||||
entry = JSON.parse(line) as typeof entry;
|
||||
} catch {
|
||||
continue; // the first tail line is usually cut mid-JSON
|
||||
}
|
||||
const { sessionId, timestamp } = entry;
|
||||
if (entry.project !== session.workingDir) continue;
|
||||
if (typeof sessionId !== 'string' || !sessionId) continue;
|
||||
if (typeof timestamp !== 'number') continue;
|
||||
if (otherClaudeIds.has(sessionId)) continue;
|
||||
const dist = Math.abs(timestamp - submitAt);
|
||||
if (dist > CLAUDE_SUBMIT_MATCH_MS) continue;
|
||||
if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist)) continue; // another pane is closer
|
||||
if (!best || dist <= best.dist) best = { sessionId, dist }; // ties: the newer entry wins
|
||||
}
|
||||
if (!best) return null;
|
||||
|
||||
// Sanity: the conversation we switch to must exist on disk and must not be
|
||||
// staler than the one we are leaving. A `/clear` successor never is.
|
||||
const currentSessionId = session.claudeSessionId || session.id;
|
||||
if (best.sessionId !== currentSessionId) {
|
||||
const projectDirs = await fs.readdir(projectsDir).catch(() => null);
|
||||
if (!projectDirs) return null;
|
||||
let candidateMtime = 0;
|
||||
let initialMtime = 0;
|
||||
let currentMtime = 0;
|
||||
for (const projDir of projectDirs) {
|
||||
try {
|
||||
const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
|
||||
if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs;
|
||||
} catch {
|
||||
/* not in this dir */
|
||||
}
|
||||
try {
|
||||
const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
|
||||
if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs;
|
||||
} catch {
|
||||
/* not in this dir */
|
||||
}
|
||||
const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null);
|
||||
if (candidateStat && candidateStat.mtimeMs > candidateMtime) candidateMtime = candidateStat.mtimeMs;
|
||||
const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null);
|
||||
if (currentStat && currentStat.mtimeMs > currentMtime) currentMtime = currentStat.mtimeMs;
|
||||
}
|
||||
if (candidateMtime === 0) return null;
|
||||
if (initialMtime > 0 && candidateMtime <= initialMtime) return null;
|
||||
} catch {
|
||||
return null;
|
||||
if (candidateMtime === 0) return null; // transcript not written yet — retry next poll
|
||||
if (currentMtime > 0 && candidateMtime < currentMtime) return null;
|
||||
}
|
||||
return candidateSid;
|
||||
|
||||
claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId });
|
||||
return best.sessionId;
|
||||
}
|
||||
|
||||
interface ClaudeResponseMessage {
|
||||
@@ -1211,6 +1253,11 @@ export function registerSessionRoutes(
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||
if (activeId && activeId !== session.claudeSessionId) {
|
||||
session.adoptClaudeSessionId(activeId);
|
||||
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
|
||||
// emits no completion event, so without this the anchor could still be
|
||||
// unpersisted when the server restarts — and recovery would fall back to
|
||||
// the launch conversation.
|
||||
ctx.persistSessionState(session);
|
||||
// Docker sessions: keep the case's resume seed following the live conversation.
|
||||
if (session.docker) {
|
||||
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
|
||||
@@ -1284,7 +1331,7 @@ export function registerSessionRoutes(
|
||||
return { cwd, originator };
|
||||
}
|
||||
|
||||
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
|
||||
// The pane's last Enter (Session.lastSubmitAt) correlated against
|
||||
// ~/.codex/history.jsonl, which logs every submitted user message as
|
||||
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
|
||||
// is the only signal that survives /resume, /new and /fork typed inside the
|
||||
@@ -1293,10 +1340,10 @@ export function registerSessionRoutes(
|
||||
// can't steal the attribution.
|
||||
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
|
||||
async function resolveCodexThreadFromHistory(
|
||||
session: { id: string; codexLastSubmitAt?: number },
|
||||
session: { id: string; lastSubmitAt?: number },
|
||||
codexHome: string
|
||||
): Promise<string | null> {
|
||||
const submitAt = session.codexLastSubmitAt || 0;
|
||||
const submitAt = session.lastSubmitAt || 0;
|
||||
if (!submitAt) return null;
|
||||
const cached = codexHistoryPinCache.get(session.id);
|
||||
if (cached && cached.submitAt === submitAt) return cached.threadId;
|
||||
@@ -1310,8 +1357,8 @@ export function registerSessionRoutes(
|
||||
const WINDOW_MS = 15_000;
|
||||
const otherSubmits: number[] = [];
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
|
||||
otherSubmits.push(s.codexLastSubmitAt);
|
||||
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
|
||||
otherSubmits.push(s.lastSubmitAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,7 +1401,7 @@ export function registerSessionRoutes(
|
||||
async function findActiveCodexFile(session: {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
codexLastSubmitAt?: number;
|
||||
lastSubmitAt?: number;
|
||||
codexConfig?: { resumeSessionId?: string };
|
||||
}): Promise<string | null> {
|
||||
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
|
||||
@@ -1943,6 +1990,7 @@ export function registerSessionRoutes(
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
@@ -1988,6 +2036,7 @@ export function registerSessionRoutes(
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2018,6 +2067,7 @@ export function registerSessionRoutes(
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2110,6 +2160,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check Antigravity availability if requested
|
||||
if (mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
@@ -2156,8 +2217,8 @@ export function registerSessionRoutes(
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
|
||||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
|
||||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
|
||||
@@ -2177,7 +2238,14 @@ export function registerSessionRoutes(
|
||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||
// configured project. Skipped for external CLIs (they use their own systems).
|
||||
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
if (
|
||||
docker &&
|
||||
docker.hooksEnabled &&
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity'
|
||||
) {
|
||||
try {
|
||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
@@ -2206,6 +2274,7 @@ export function registerSessionRoutes(
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
@@ -2224,17 +2293,19 @@ export function registerSessionRoutes(
|
||||
? codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? geminiConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'antigravity'
|
||||
? antigravityConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig
|
||||
);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const {
|
||||
codexConfig: qsGatedCodexConfig,
|
||||
geminiConfig: qsGatedGeminiConfig,
|
||||
antigravityConfig: qsGatedAntigravityConfig,
|
||||
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
@@ -2250,6 +2321,7 @@ export function registerSessionRoutes(
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -2406,6 +2478,64 @@ export function registerSessionRoutes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this `entrypoint` value an automated/SDK-driven invocation?
|
||||
*
|
||||
* ⚠️ Deliberately a BLOCKLIST on the SDK shape, not an allowlist on `'cli'`.
|
||||
* The exclusion below hides rows, so an allowlist fails CLOSED on any value
|
||||
* Claude Code has not shipped yet: the day it stamps a new interactive
|
||||
* entrypoint (a rename, or a second interactive host), every transcript stops
|
||||
* matching `'cli'` and the whole Past Sessions list goes blank with nothing in
|
||||
* the UI to explain it. A blocklist fails OPEN instead — an automated
|
||||
* entrypoint we do not recognize yet costs a few noisy rows, which is the
|
||||
* annoyance this filter set out to fix rather than a broken feature.
|
||||
*
|
||||
* Observed values: `cli` (interactive), `sdk-cli` / `sdk-py` (automated).
|
||||
*/
|
||||
function isAutomatedEntrypoint(entrypoint: string): boolean {
|
||||
return /^sdk(-|$)/.test(entrypoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `entrypoint` field Claude Code stamps on its own message records:
|
||||
* 'cli' for a real interactive session, something else (e.g. 'sdk-py') for
|
||||
* an SDK/automated invocation. Used to exclude non-interactive transcripts
|
||||
* (CI review bots, etc.) from the resumable history list — they were never
|
||||
* something a user can resume into.
|
||||
*
|
||||
* Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint
|
||||
* field — not just the first one — and returns 'cli' the moment ANY of them
|
||||
* carries it. A transcript is excluded only when every entrypoint-bearing
|
||||
* message says something else; "first field wins" would misattribute a
|
||||
* transcript that started under an older Claude Code version (no entrypoint
|
||||
* on its true first message) and later picked up a non-'cli' entrypoint on
|
||||
* some later message, wrongly hiding a genuinely interactive session. This
|
||||
* deliberately errs toward keeping a session visible: one real interactive
|
||||
* message anywhere is enough. Returns undefined ("unknown", fail-open) only
|
||||
* when nothing scanned carries the field at all.
|
||||
*/
|
||||
function extractTranscriptEntrypoint(text: string): string | undefined {
|
||||
let start = 0;
|
||||
let sawNonCli: string | undefined;
|
||||
while (start < text.length) {
|
||||
const end = text.indexOf('\n', start);
|
||||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||||
start = end === -1 ? text.length : end + 1;
|
||||
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
|
||||
if (!line.includes('"entrypoint"')) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') {
|
||||
if (entry.entrypoint === 'cli') return 'cli';
|
||||
sawNonCli ??= entry.entrypoint;
|
||||
}
|
||||
} catch {
|
||||
// Malformed/truncated line — skip
|
||||
}
|
||||
}
|
||||
return sawNonCli;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||||
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||||
@@ -2501,7 +2631,12 @@ export function registerSessionRoutes(
|
||||
for (let end = maxLook - 1; end >= idx; end--) {
|
||||
const candidates: string[] = [];
|
||||
if (end === idx) {
|
||||
candidates.push(segments[idx]);
|
||||
// Skip an EMPTY segment: `isDir(current + '/' + '')` stats `current + '/'`,
|
||||
// which always succeeds, so the empty candidate would match unconditionally
|
||||
// and swallow the doubled dash that is the whole signature of a dotdir. It
|
||||
// then resolves "/home/x/.sib" to "/home/x//sib" whenever a non-dot sibling
|
||||
// exists, and shadows the dotdir branch below in every other case.
|
||||
if (segments[idx] !== '') candidates.push(segments[idx]);
|
||||
} else {
|
||||
candidates.push(segments.slice(idx, end + 1).join('-'));
|
||||
candidates.push(segments.slice(idx, end + 1).join('_'));
|
||||
@@ -2514,6 +2649,25 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The encoder maps both '/' and '.' to '-', so a literal '.' in the
|
||||
// original path (e.g. "/home/timkjr/.codeman") collapses into an empty
|
||||
// split segment here. Retry this window as a dotdir/dotfile: ".<join>".
|
||||
if (segments[idx] === '' && idx + 1 < segments.length) {
|
||||
const dotMaxLook = Math.min(idx + 1 + 4, segments.length);
|
||||
for (let end = dotMaxLook - 1; end >= idx + 1; end--) {
|
||||
const dotCandidates =
|
||||
end === idx + 1
|
||||
? [segments[idx + 1]]
|
||||
: [segments.slice(idx + 1, end + 1).join('-'), segments.slice(idx + 1, end + 1).join('_')];
|
||||
for (const child of dotCandidates) {
|
||||
const candidate = current + '/.' + child;
|
||||
if (await isDir(candidate)) {
|
||||
const result = await tryDecode(end + 1, candidate);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2531,7 +2685,10 @@ export function registerSessionRoutes(
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
// Same empty-segment skip as tryDecode above. This loop is shortest-match
|
||||
// first, so without it the empty candidate matches on the very first try
|
||||
// and sets `matched`, leaving the dotdir branch below permanently dead.
|
||||
if (segments[i] !== '') candidates.push(segments[i]);
|
||||
} else {
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
@@ -2547,9 +2704,41 @@ export function registerSessionRoutes(
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched && segments[i] === '' && i + 1 < segments.length) {
|
||||
const dotMaxLook = Math.min(i + 1 + 4, segments.length);
|
||||
for (let end = i + 1; end < dotMaxLook; end++) {
|
||||
const dotCandidates =
|
||||
end === i + 1
|
||||
? [segments[i + 1]]
|
||||
: [segments.slice(i + 1, end + 1).join('_'), segments.slice(i + 1, end + 1).join('-')];
|
||||
for (const child of dotCandidates) {
|
||||
const candidate = current + '/.' + child;
|
||||
if (await isDir(candidate)) {
|
||||
current = candidate;
|
||||
i = end + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
if (segments[i] === '') {
|
||||
// Nothing on disk matched (the usual reason this fallback runs at all is
|
||||
// that the directory was deleted). An empty segment still means the
|
||||
// encoder ate a literal '.', so guess the dotdir form rather than
|
||||
// appending a bare '/' and emitting a "//" path.
|
||||
if (i + 1 < segments.length) {
|
||||
current = current + '/.' + segments[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalExists = await fs
|
||||
@@ -2559,7 +2748,7 @@ export function registerSessionRoutes(
|
||||
return finalExists ? current : process.env.HOME || '/tmp';
|
||||
}
|
||||
|
||||
/** Read the first 16KB of a file for content sniffing. */
|
||||
/** Read the first `buf.length` bytes of a file for content sniffing. */
|
||||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
const fd = await fs.open(path, 'r');
|
||||
@@ -2602,7 +2791,12 @@ export function registerSessionRoutes(
|
||||
|
||||
// Scan a single project directory and return all valid history sessions in it.
|
||||
// Reused by both the global overview and the single-folder drill-down.
|
||||
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
|
||||
async function scanProjectDir(
|
||||
projPath: string,
|
||||
projDir: string,
|
||||
smallHeadBuf: Buffer,
|
||||
headBuf: Buffer
|
||||
): Promise<HistorySession[]> {
|
||||
const out: HistorySession[] = [];
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) return out;
|
||||
@@ -2620,22 +2814,45 @@ export function registerSessionRoutes(
|
||||
if (!fileStat) continue;
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
// Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough
|
||||
// for the vast majority of transcripts -- and only escalate to the full
|
||||
// headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for
|
||||
// EVERY file in the directory roughly quadrupled the cost of a full scan
|
||||
// (measured against a real ~/.claude/projects tree: ~4x both bytes read and
|
||||
// wall time) to fix a problem only ~28% of files actually have. Escalating
|
||||
// resolves the restart-bookkeeping case (the reason 128KB exists at all)
|
||||
// without ever touching the tail-read fallback below for most of that 28%.
|
||||
let head = await readFileHead(filePath, smallHeadBuf);
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
let firstPrompt = head ? extractFirstUserPrompt(head) : undefined;
|
||||
if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) {
|
||||
const biggerHead = await readFileHead(filePath, headBuf);
|
||||
if (biggerHead) {
|
||||
head = biggerHead;
|
||||
if (!foundContent) foundContent = hasConversation(head);
|
||||
if (!firstPrompt) firstPrompt = extractFirstUserPrompt(head);
|
||||
}
|
||||
}
|
||||
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
// `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of
|
||||
// files) must also get a shot at the tail, not just "file bigger than the
|
||||
// head buffer". Losing this dropped the session from history entirely
|
||||
// instead of giving it a second chance, for any file at or under the head
|
||||
// buffer size whose head read happened to fail.
|
||||
if (!foundContent && (head === null || fileStat.size > headBuf.length)) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
if (!foundContent) continue;
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
// firstPrompt was already attempted from head (both tiers) above; this is
|
||||
// purely the tail fallback for whatever's left unresolved.
|
||||
if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) {
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
@@ -2645,15 +2862,37 @@ export function registerSessionRoutes(
|
||||
|
||||
// COD-145: last (most recent) user prompt lives near the END of the file, so
|
||||
// prefer the tail. For large files where no tail was read yet, read one
|
||||
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
|
||||
// then contains the whole transcript — scan it for the last match instead.
|
||||
if (!tail && fileStat.size > 65536) {
|
||||
// (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`,
|
||||
// which then contains the whole transcript — scan it for the last match instead.
|
||||
if (!tail && fileStat.size > headBuf.length) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
const lastPrompt =
|
||||
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
|
||||
|
||||
// Automated/SDK-driven invocations (CI review bots, etc.) write transcripts
|
||||
// into the same ~/.claude/projects tree as interactive sessions but were
|
||||
// never something a user can resume into — no PTY, no running process, and
|
||||
// their "conversation" is typically a single one-shot prompt (often with a
|
||||
// full diff embedded, which is exactly why it dwarfs this scanner's read
|
||||
// windows and shows up above as blank or as an identical boilerplate
|
||||
// sentence across many rows). Checked last, so it reuses whatever `head`/
|
||||
// `tail` the prompt extraction above already read rather than triggering
|
||||
// an extra file read. Missing entrypoint (older transcripts) reads as
|
||||
// interactive — fail open, matching every other gating check in this
|
||||
// codebase.
|
||||
//
|
||||
// head and tail are checked independently and merged with "cli wins" (not
|
||||
// a first-truthy-value `??` chain): a large file's head might land on a
|
||||
// non-'cli' message while a real interactive message sits in the tail (or
|
||||
// vice versa), and either one being 'cli' is enough to keep the session.
|
||||
const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined;
|
||||
const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined;
|
||||
const entrypoint =
|
||||
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
|
||||
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
|
||||
|
||||
out.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
@@ -2670,7 +2909,13 @@ export function registerSessionRoutes(
|
||||
app.get('/api/history/sessions', async (req) => {
|
||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
// scanProjectDir tries smallHeadBuf (16KB, the original size) first for every
|
||||
// file and only escalates to headBuf (128KB) when that wasn't enough — see the
|
||||
// comment at the escalation site in scanProjectDir for why unconditional 128KB
|
||||
// reads were too expensive to keep. 128KB matches the existing precedent
|
||||
// elsewhere in this file (line ~1431).
|
||||
const smallHeadBuf = Buffer.alloc(16384);
|
||||
const headBuf = Buffer.alloc(131072);
|
||||
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
||||
// must only see history whose decoded workingDir is inside their own case space.
|
||||
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
||||
@@ -2688,7 +2933,7 @@ export function registerSessionRoutes(
|
||||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||
const projPath = join(projectsDir, query.projectKey);
|
||||
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf);
|
||||
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
||||
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
@@ -2701,7 +2946,7 @@ export function registerSessionRoutes(
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
const list = await scanProjectDir(projPath, projDir, headBuf);
|
||||
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
|
||||
results.push(...list);
|
||||
}
|
||||
} catch {
|
||||
@@ -2777,11 +3022,13 @@ export function registerSessionRoutes(
|
||||
const history: HistoryInput[] = [];
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
// See the sibling allocation above for why there are two sizes.
|
||||
const smallHeadBuf = Buffer.alloc(16384);
|
||||
const headBuf = Buffer.alloc(131072);
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
const list = await scanProjectDir(projPath, projDir, headBuf);
|
||||
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
|
||||
for (const h of list) {
|
||||
history.push({
|
||||
sessionId: h.sessionId,
|
||||
|
||||
@@ -374,9 +374,19 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode, Codex, Gemini)
|
||||
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Claude ==========
|
||||
|
||||
app.get('/api/claude/status', async () => {
|
||||
const { isClaudeAvailable, findClaudeDir } = await import('../../utils/claude-cli-resolver.js');
|
||||
return {
|
||||
available: isClaudeAvailable(),
|
||||
path: findClaudeDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// ========== OpenCode ==========
|
||||
|
||||
app.get('/api/opencode/status', async () => {
|
||||
@@ -405,6 +415,16 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Antigravity ==========
|
||||
|
||||
app.get('/api/antigravity/status', async () => {
|
||||
const { isAntigravityAvailable, resolveAntigravityDir } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
return {
|
||||
available: isAntigravityAvailable(),
|
||||
path: resolveAntigravityDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+52
-5
@@ -16,6 +16,7 @@ import {
|
||||
MIN_TERMINAL_BUFFER_BYTES,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -83,10 +84,34 @@ export const FilesystemPreviewQuerySchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Body validation for `PUT /api/sessions/:id/file-content` (File Viewer edit
|
||||
* mode). `content.max()` counts UTF-16 code units, which for UTF-8 output is
|
||||
* always <= the byte length, so it is a coarse pre-filter that never rejects
|
||||
* valid content; the handler enforces the exact MAX_EDITABLE_BYTES byte cap.
|
||||
* Workspace containment and symlink resolution are enforced by the route via
|
||||
* validateSessionFilePath after parsing.
|
||||
*/
|
||||
export const FileWriteSchema = z
|
||||
.object({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4096)
|
||||
.refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||
message: 'Invalid path',
|
||||
}),
|
||||
content: z.string().max(MAX_EDITABLE_BYTES),
|
||||
baseHash: z.string().regex(/^[a-f0-9]{64}$/, 'baseHash must be a sha256 hex digest'),
|
||||
eol: z.enum(['lf', 'crlf']).optional(),
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
|
||||
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
@@ -116,7 +141,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -182,6 +207,7 @@ const CodexConfigSchema = z
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.optional(),
|
||||
dangerouslyBypassApprovals: z.boolean().optional(),
|
||||
animations: z.boolean().optional(),
|
||||
renderMode: z
|
||||
.enum(['scrollback', 'hybrid'])
|
||||
.optional()
|
||||
@@ -206,9 +232,26 @@ const GeminiConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Schema for Antigravity CLI (agy)-specific configuration */
|
||||
const AntigravityConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
dangerouslySkipPermissions: z.boolean().optional(),
|
||||
resumeConversationId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
@@ -220,6 +263,7 @@ export const CreateSessionSchema = z.object({
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
@@ -328,6 +372,7 @@ const RemoteCommandOverridesSchema = z
|
||||
opencode: z.string().min(1).max(300).optional(),
|
||||
codex: z.string().min(1).max(300).optional(),
|
||||
gemini: z.string().min(1).max(300).optional(),
|
||||
antigravity: z.string().min(1).max(300).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -600,10 +645,11 @@ export const QuickStartSchema = z.object({
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
* remote cases (the file would be written on the WRONG machine). */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -756,6 +802,7 @@ export const SettingsUpdateSchema = z
|
||||
allowedTools: z.string().max(2000).optional(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||
codexAnimationsEnabled: z.boolean().optional(),
|
||||
// Terminal history and retention
|
||||
terminalScrollbackLines: z
|
||||
.number()
|
||||
@@ -957,7 +1004,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
const CronJobBaseSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']),
|
||||
workingDir: safePathSchema,
|
||||
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
||||
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
||||
|
||||
@@ -1288,6 +1288,49 @@ export class WebServer extends EventEmitter {
|
||||
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
|
||||
// show the toggle only when the feature is available, and inject the bundle
|
||||
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
|
||||
// Tool availability (#200/#201): the welcome-screen run buttons, the run-mode
|
||||
// dropdown entries and the App Settings "Codex CLI" tab are all offers that a
|
||||
// box without the binary cannot keep — picking one spawns a session that
|
||||
// errors out immediately. One object answers all three.
|
||||
//
|
||||
// INJECTED, not fetched per surface. The `/api/<cli>/status` routes exist and
|
||||
// stay (they mirror each other and are a fine API surface), but as the source
|
||||
// for UI gating they buy nothing: every resolver memoizes its PATH probe on
|
||||
// the server, so a fetch is exactly as stale as an injected value, while
|
||||
// costing a round trip each time the dropdown opens and leaving the welcome
|
||||
// buttons to flicker in after paint. Installing a CLI later needs a server
|
||||
// restart either way. Memoized probes also make this cheap per render.
|
||||
//
|
||||
// Solo popups skip it: no settings modal, no welcome screen, no run menu.
|
||||
if (!soloSessionId) {
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
] = await Promise.all([
|
||||
import('../utils/claude-cli-resolver.js'),
|
||||
import('../utils/opencode-cli-resolver.js'),
|
||||
import('../utils/codex-cli-resolver.js'),
|
||||
import('../utils/gemini-cli-resolver.js'),
|
||||
import('../utils/antigravity-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
]);
|
||||
const available = {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
};
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
@@ -2461,9 +2504,14 @@ export class WebServer extends EventEmitter {
|
||||
openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
|
||||
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
|
||||
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
|
||||
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
// The pane's last Enter. Without it the response viewer would show
|
||||
// the launch conversation until the user types again, even though
|
||||
// the re-attached CLI is on a post-`/clear` one.
|
||||
lastSubmitAt: savedState?.lastSubmitAt,
|
||||
// Remote SSH metadata must round-trip on recovery: without it the
|
||||
// attach cwd falls back to the (nonexistent-locally) remote path and
|
||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
|
||||
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
|
||||
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
|
||||
|
||||
describe('Antigravity mode schemas', () => {
|
||||
it('accepts Antigravity session creation config', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'antigravity',
|
||||
antigravityConfig: {
|
||||
model: 'gemini-3-pro',
|
||||
dangerouslySkipPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.mode).toBe('antigravity');
|
||||
expect(parsed.antigravityConfig).toEqual({
|
||||
model: 'gemini-3-pro',
|
||||
dangerouslySkipPermissions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Antigravity quick-start config', () => {
|
||||
const parsed = QuickStartSchema.parse({
|
||||
caseName: 'antigravity-case',
|
||||
mode: 'antigravity',
|
||||
antigravityConfig: {
|
||||
resumeConversationId: 'conv-1234abcd',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.mode).toBe('antigravity');
|
||||
expect(parsed.antigravityConfig?.resumeConversationId).toBe('conv-1234abcd');
|
||||
});
|
||||
|
||||
it('rejects unsafe Antigravity model strings', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'antigravity',
|
||||
antigravityConfig: { model: 'agy; rm -rf /' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('allows ANTIGRAVITY_* env overrides and still rejects unknown prefixes', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'antigravity',
|
||||
envOverrides: { ANTIGRAVITY_LOG_LEVEL: 'debug' },
|
||||
});
|
||||
expect(parsed.envOverrides).toEqual({ ANTIGRAVITY_LOG_LEVEL: 'debug' });
|
||||
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
envOverrides: { RANDOM_PREFIX_KEY: 'x' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Antigravity spawn command', () => {
|
||||
it('builds a bare agy command when no config is sent (safe default, no bypass)', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'antigravity', sessionId: 'abc12345' });
|
||||
expect(cmd).toBe('agy');
|
||||
});
|
||||
|
||||
it('adds --dangerously-skip-permissions only when explicitly requested', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'antigravity',
|
||||
sessionId: 'abc12345',
|
||||
antigravityConfig: { dangerouslySkipPermissions: true, model: 'gemini-3-pro' },
|
||||
});
|
||||
expect(cmd).toBe('agy --dangerously-skip-permissions --model gemini-3-pro');
|
||||
});
|
||||
|
||||
it('passes --conversation for resume and drops unsafe ids', () => {
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'antigravity',
|
||||
sessionId: 'abc12345',
|
||||
antigravityConfig: { resumeConversationId: 'conv-99' },
|
||||
})
|
||||
).toBe('agy --conversation conv-99');
|
||||
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'antigravity',
|
||||
sessionId: 'abc12345',
|
||||
antigravityConfig: { resumeConversationId: 'x; rm -rf /' },
|
||||
})
|
||||
).toBe('agy');
|
||||
});
|
||||
|
||||
it('drops unsafe model strings from the spawn command', () => {
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'antigravity',
|
||||
sessionId: 'abc12345',
|
||||
antigravityConfig: { model: 'a`b' },
|
||||
})
|
||||
).toBe('agy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Antigravity mode gates', () => {
|
||||
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
|
||||
expect(isExternalCliMode('antigravity')).toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT an alt-screen strip mode (unverified Go TUI, like opencode)', () => {
|
||||
expect(isAltScreenStripMode('antigravity')).toBe(false);
|
||||
});
|
||||
|
||||
it('has docker/remote default commands', () => {
|
||||
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
|
||||
// Routed through an interactive login shell so per-user PATH entries resolve —
|
||||
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
|
||||
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'agy\'');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the File Viewer edit-mode policy module.
|
||||
*
|
||||
* Pure functions only — no IO, no server.
|
||||
* Port: N/A (no server)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MAX_EDITABLE_BYTES,
|
||||
applyEol,
|
||||
detectEol,
|
||||
isDeniedEditRelativePath,
|
||||
isEditableFileName,
|
||||
} from '../src/config/file-editing.js';
|
||||
|
||||
describe('file-editing policy', () => {
|
||||
describe('isEditableFileName', () => {
|
||||
it('allows common text extensions', () => {
|
||||
for (const name of ['a.ts', 'b.md', 'c.json', 'd.py', 'style.css', 'notes.txt', 'x.yml', 'Q.SQL']) {
|
||||
expect(isEditableFileName(name), name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows well-known basenames regardless of case', () => {
|
||||
for (const name of ['Dockerfile', 'Makefile', 'LICENSE', '.gitignore', '.editorconfig', '.nvmrc']) {
|
||||
expect(isEditableFileName(name), name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects binary/media/document extensions', () => {
|
||||
for (const name of ['a.png', 'b.pdf', 'c.docx', 'd.zip', 'e.woff2', 'f.mp4', 'g.exe']) {
|
||||
expect(isEditableFileName(name), name).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects svg and env (deliberate v1 exclusions)', () => {
|
||||
expect(isEditableFileName('image.svg')).toBe(false);
|
||||
expect(isEditableFileName('config.env')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extensionless and unknown-dotfile names not on the basename list', () => {
|
||||
expect(isEditableFileName('somebinary')).toBe(false);
|
||||
expect(isEditableFileName('.bashrc')).toBe(false);
|
||||
expect(isEditableFileName('archive.xyz')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDeniedEditRelativePath', () => {
|
||||
it('denies anything inside a .git directory at any depth', () => {
|
||||
expect(isDeniedEditRelativePath('.git/config')).toBe(true);
|
||||
expect(isDeniedEditRelativePath('.git/hooks/pre-commit')).toBe(true);
|
||||
expect(isDeniedEditRelativePath('sub/module/.git/HEAD')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows non-.git paths, including names merely containing "git"', () => {
|
||||
expect(isDeniedEditRelativePath('src/index.ts')).toBe(false);
|
||||
expect(isDeniedEditRelativePath('.github/workflows/ci.yml')).toBe(false);
|
||||
expect(isDeniedEditRelativePath('digits/file.md')).toBe(false);
|
||||
expect(isDeniedEditRelativePath('.gitignore')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEol / applyEol', () => {
|
||||
it('detects LF, CRLF, and defaults to LF for single-line text', () => {
|
||||
expect(detectEol('a\nb\nc')).toBe('lf');
|
||||
expect(detectEol('a\r\nb\r\nc')).toBe('crlf');
|
||||
expect(detectEol('no newline at all')).toBe('lf');
|
||||
expect(detectEol('')).toBe('lf');
|
||||
});
|
||||
|
||||
it('picks the dominant style for mixed-EOL text', () => {
|
||||
expect(detectEol('a\r\nb\r\nc\nd')).toBe('crlf');
|
||||
expect(detectEol('a\nb\nc\r\nd')).toBe('lf');
|
||||
});
|
||||
|
||||
it('applyEol round-trips a textarea-normalized (LF) buffer back to CRLF', () => {
|
||||
const original = 'line1\r\nline2\r\nline3';
|
||||
const textareaValue = original.replace(/\r\n/g, '\n');
|
||||
expect(applyEol(textareaValue, detectEol(original))).toBe(original);
|
||||
});
|
||||
|
||||
it('applyEol is idempotent and never doubles CR', () => {
|
||||
expect(applyEol('a\r\nb', 'crlf')).toBe('a\r\nb');
|
||||
expect(applyEol('a\r\nb', 'lf')).toBe('a\nb');
|
||||
expect(applyEol('a\nb', 'lf')).toBe('a\nb');
|
||||
});
|
||||
|
||||
it('preserves a UTF-8 BOM through the EOL rewrite', () => {
|
||||
const withBom = 'hello\nworld';
|
||||
expect(applyEol(withBom, 'crlf')).toBe('hello\r\nworld');
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes a sane editable-bytes cap', () => {
|
||||
expect(MAX_EDITABLE_BYTES).toBe(512 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ describe('help modal shortcuts', () => {
|
||||
});
|
||||
|
||||
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'C'], 'Copy Selection');
|
||||
expectShortcut(helpModal, ['Ctrl', 'L'], 'Clear Terminal');
|
||||
expectShortcut(helpModal, ['Ctrl', '+'], 'Increase Font');
|
||||
expectShortcut(helpModal, ['Ctrl', '-'], 'Decrease Font');
|
||||
|
||||
@@ -245,6 +245,119 @@ describe('Inline rename input', () => {
|
||||
expect(result.threw).toBe(false);
|
||||
});
|
||||
|
||||
it('Render guard: _renderSessionTabsImmediate() does not destroy an open rename input', async () => {
|
||||
await resetState();
|
||||
|
||||
// The debounced tab render is scheduled by renderSessionTabs() but EXECUTED by
|
||||
// _renderSessionTabsImmediate(). A render queued just before the rename opened
|
||||
// still fires ~100ms later and lands in the executor directly, so the guard has
|
||||
// to live there too, otherwise the incremental branch rewrites .tab-name's
|
||||
// innerHTML and the user's half-typed description is lost.
|
||||
//
|
||||
// The tab MUST live inside the real #sessionTabs container and be the only
|
||||
// session in app.sessions: the renderer walks that container, so a synthetic
|
||||
// node parked on <body> would make this test pass with the guard removed.
|
||||
const result = await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string; status: string }>;
|
||||
sessionOrder: string[];
|
||||
startInlineRename: (id: string) => void;
|
||||
_renderSessionTabsImmediate: () => void;
|
||||
_activeRename: unknown;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
const id = 'render-race';
|
||||
app.sessions.set(id, { id, name: 'w9-case', status: 'idle' });
|
||||
app.sessionOrder = [id];
|
||||
|
||||
const container = document.getElementById('sessionTabs') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.id = id;
|
||||
tab.innerHTML =
|
||||
'<span class="tab-status idle"></span><span class="tab-info"><span class="tab-name-row">' +
|
||||
`<span class="tab-name" data-session-id="${id}">w9-case</span>` +
|
||||
'</span></span>';
|
||||
container.appendChild(tab);
|
||||
|
||||
app.startInlineRename(id);
|
||||
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement | null;
|
||||
if (!input) return { opened: false };
|
||||
input.value = 'half-typed';
|
||||
|
||||
// Exactly what a debounce timer queued before the rename would do.
|
||||
app._renderSessionTabsImmediate();
|
||||
|
||||
const after = document.querySelector('input.tab-rename-input') as HTMLInputElement | null;
|
||||
return {
|
||||
opened: true,
|
||||
stillInDom: !!after && document.body.contains(after),
|
||||
value: after?.value ?? null,
|
||||
renameStillActive: !!app._activeRename,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.opened).toBe(true);
|
||||
expect(result.stillInDom).toBe(true);
|
||||
expect(result.value).toBe('half-typed');
|
||||
expect(result.renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Modal: closeSessionOptions() commits the Session Name field before clearing the id', async () => {
|
||||
await resetState();
|
||||
|
||||
// Every autosave handler in the session-options modal bails on a null
|
||||
// editingSessionId, and hiding the modal blurs the focused input. If the id is
|
||||
// cleared first, the blur-driven save is dropped and the typed name vanishes,
|
||||
// which is what Escape and backdrop-click used to do.
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
editingSessionId: string | null;
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
closeSessionOptions: () => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
app.sessions.set('modal-id', { id: 'modal-id', name: 'w9-case' });
|
||||
app.editingSessionId = 'modal-id';
|
||||
|
||||
const nameInput = document.getElementById('modalSessionName') as HTMLInputElement;
|
||||
const modal = document.getElementById('sessionOptionsModal') as HTMLElement;
|
||||
modal.classList.add('active');
|
||||
// The Session Name field lives on the modal's Context tab, which is hidden
|
||||
// until selected: a hidden input cannot take focus.
|
||||
document.getElementById('context-tab')?.classList.remove('hidden');
|
||||
nameInput.value = 'mydesc';
|
||||
nameInput.focus();
|
||||
const wasFocused = document.activeElement === nameInput;
|
||||
|
||||
let putBody: string | null = null;
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).includes('/api/sessions/modal-id/name')) putBody = String(init?.body ?? '');
|
||||
return new Response('{"success":true}', { status: 200 });
|
||||
}) as typeof window.fetch;
|
||||
|
||||
app.closeSessionOptions();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
window.fetch = origFetch;
|
||||
modal.classList.remove('active');
|
||||
|
||||
return { wasFocused, putBody, editingAfter: app.editingSessionId };
|
||||
});
|
||||
|
||||
expect(result.wasFocused).toBe(true);
|
||||
// Prefixed session: the suffix the user typed is appended to the w9-case prefix.
|
||||
expect(result.putBody).toContain('w9-case: mydesc');
|
||||
expect(result.editingAfter).toBe(null);
|
||||
});
|
||||
|
||||
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('first-id', 'First')).toBe(true);
|
||||
|
||||
@@ -58,4 +58,22 @@ describe('keyboard shortcuts', () => {
|
||||
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
|
||||
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
|
||||
});
|
||||
|
||||
it('keeps the interrupt when Ctrl+C copies a selection (#211)', () => {
|
||||
// The xterm handler owns this decision, and the no-selection path must fall
|
||||
// through with NO preventDefault so xterm still evaluates Ctrl+C into 0x03.
|
||||
expect(terminalUiSource).toContain('this.shouldCopyTerminalSelectionFromShortcut?.(ev)');
|
||||
expect(terminalUiSource).toMatch(
|
||||
/const selection = this\.terminal\.hasSelection\?\.\(\) \? this\.terminal\.getSelection\(\) : '';/
|
||||
);
|
||||
expect(terminalUiSource).toContain('void this.copyTerminalSelection(selection);');
|
||||
expect(appSource).toContain("id: 'copy-selection'");
|
||||
});
|
||||
|
||||
it('documents the terminal copy shortcut in help and README', () => {
|
||||
expect(helpHtml).toContain('<kbd>Ctrl</kbd>+<kbd>C</kbd>');
|
||||
expect(helpHtml).toContain('<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>C</kbd>');
|
||||
expect(readme).toContain('`Ctrl/Cmd+C`');
|
||||
expect(readme).toContain('`Ctrl+Shift+C`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Port: none (pure model + static markup assertions — no browser, no server).
|
||||
//
|
||||
// The phone home screen (src/web/public/mobile-overview.js) replaces the welcome
|
||||
// overlay under 430px. Its grouping logic is the part that can silently go wrong:
|
||||
// a session blocked on a permission prompt landing in "idle" is exactly the bug
|
||||
// this surface exists to prevent. buildMobileOverviewModel() is pure for that
|
||||
// reason, so it can be exercised here against plain objects.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
/** Minimal fake DOM node — enough surface for mobile-overview.js's programmatic builders. */
|
||||
function fakeElement(): any {
|
||||
const el: any = {
|
||||
className: '',
|
||||
type: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
children: [] as any[],
|
||||
setAttribute() {},
|
||||
appendChild(child: any) {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function loadOverviewApp(overrides: Record<string, any> = {}) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: () => null,
|
||||
createElement: () => fakeElement(),
|
||||
createElementNS: () => fakeElement(),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => 'mobile' },
|
||||
});
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
|
||||
filename: 'mobile-overview.js',
|
||||
});
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
||||
app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/');
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
Object.assign(app, overrides);
|
||||
return app;
|
||||
}
|
||||
|
||||
const CASES = [
|
||||
{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' },
|
||||
{ name: 'beta', path: '/home/arkon/codeman-cases/beta', location: 'local' },
|
||||
{ name: 'boxed', path: '/srv/boxed', location: 'docker' },
|
||||
];
|
||||
|
||||
function session(over: Record<string, any>) {
|
||||
return { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over };
|
||||
}
|
||||
|
||||
describe('mobile overview model', () => {
|
||||
it('routes a session with a pending permission prompt into NEEDS YOU, not idle', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'a', status: 'idle' })],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([['a', new Set(['permission_prompt'])]]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']);
|
||||
expect(model.current).toHaveLength(0);
|
||||
expect(model.needsYou[0].state).toBe('needs');
|
||||
expect(model.needsYou[0].pill).toBe('needs you');
|
||||
});
|
||||
|
||||
it('ranks an action hook above an idle hook above a stale busy status', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
// An idle_prompt hook on a session the server still calls 'busy': the hook
|
||||
// is the newer signal, so it must win.
|
||||
sessions: [
|
||||
session({ id: 'busy-with-idle-hook', status: 'busy' }),
|
||||
session({ id: 'elicit', status: 'busy' }),
|
||||
session({ id: 'plain-busy', status: 'busy' }),
|
||||
],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([
|
||||
['busy-with-idle-hook', new Set(['idle_prompt'])],
|
||||
['elicit', new Set(['elicitation_dialog'])],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['elicit', 'busy-with-idle-hook']);
|
||||
expect(model.current.map((r: any) => r.id)).toEqual(['plain-busy']);
|
||||
});
|
||||
|
||||
it('buckets busy / idle / stopped / error and labels each pill', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
session({ id: 'w', status: 'busy' }),
|
||||
session({ id: 'i', status: 'idle' }),
|
||||
session({ id: 'd', status: 'stopped' }),
|
||||
session({ id: 'e', status: 'error' }),
|
||||
],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
// Everything that is not blocked on you shares one "current" section,
|
||||
// most demanding first.
|
||||
expect(model.current.map((r: any) => [r.id, r.pill])).toEqual([
|
||||
['w', 'working'],
|
||||
['i', 'idle'],
|
||||
['d', 'done'],
|
||||
]);
|
||||
expect(model.needsYou.map((r: any) => r.pill)).toEqual(['error']);
|
||||
expect(model.sessionCount).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the user tab order as the tiebreak inside a section', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
||||
cases: CASES,
|
||||
sessionOrder: ['third', 'first', 'second'],
|
||||
});
|
||||
|
||||
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
||||
});
|
||||
|
||||
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
session({ id: 'sub', workingDir: '/home/arkon/default/claudeman/src/web' }),
|
||||
session({ id: 'outside', workingDir: '/tmp/scratch' }),
|
||||
],
|
||||
cases: [...CASES, { name: 'claudeman-web', path: '/home/arkon/default/claudeman/src/web' }],
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.caseName]));
|
||||
expect(rows.sub).toBe('claudeman-web');
|
||||
expect(rows.outside).toBe('');
|
||||
});
|
||||
|
||||
it('lists past conversations newest first and never repeats a live session', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'live-1' })],
|
||||
cases: CASES,
|
||||
history: [
|
||||
// Same id as the running session: the unified list includes live rows,
|
||||
// and showing one in both sections would be a duplicate.
|
||||
{ sessionId: 'live-1', workingDir: '/home/arkon/default/claudeman', lastActivityAt: 500 },
|
||||
{
|
||||
sessionId: 'old-a',
|
||||
workingDir: '/home/arkon/codeman-cases/beta',
|
||||
firstPrompt: 'fix the mobile header',
|
||||
claudeSessionId: 'claude-uuid-a',
|
||||
lastActivityAt: 100,
|
||||
},
|
||||
{
|
||||
sessionId: 'old-b',
|
||||
workingDir: '/home/arkon/default/claudeman',
|
||||
name: 'w4-claudeman',
|
||||
lastActivityAt: 400,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.past.map((r: any) => r.id)).toEqual(['old-b', 'old-a']);
|
||||
expect(model.past[1]).toMatchObject({
|
||||
title: 'fix the mobile header',
|
||||
caseName: 'beta',
|
||||
claudeSessionId: 'claude-uuid-a',
|
||||
workingDir: '/home/arkon/codeman-cases/beta',
|
||||
});
|
||||
// A row with no prompt falls back to its name, so it is never a bare UUID.
|
||||
expect(model.past[0].title).toBe('w4-claudeman');
|
||||
});
|
||||
|
||||
it('does not title a past row with the transcript reader placeholder', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [],
|
||||
cases: CASES,
|
||||
history: [
|
||||
{ sessionId: 'blank', workingDir: '/home/arkon/default/claudeman', firstPrompt: '(no content)' },
|
||||
{ sessionId: 'spaces', workingDir: '/home/arkon/codeman-cases/beta', firstPrompt: ' ' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.past.map((r: any) => r.title)).toEqual(['claudeman', 'beta']);
|
||||
});
|
||||
|
||||
it('accepts the live Map as-is and survives an empty state', () => {
|
||||
const app = loadOverviewApp();
|
||||
const fromMap = app.buildMobileOverviewModel({
|
||||
sessions: new Map([['a', session({ id: 'a' })]]),
|
||||
cases: CASES,
|
||||
});
|
||||
expect(fromMap.current.map((r: any) => r.id)).toEqual(['a']);
|
||||
|
||||
const empty = app.buildMobileOverviewModel({});
|
||||
expect(empty).toMatchObject({ needsYou: [], current: [], past: [], sessionCount: 0 });
|
||||
});
|
||||
|
||||
it('no longer builds a spaces section', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({ sessions: [session({ id: 'a' })], cases: CASES });
|
||||
expect(model.spaces).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview gate', () => {
|
||||
it('is phone-width only, off in solo windows, and off when explicitly disabled', () => {
|
||||
expect(loadOverviewApp().shouldUseMobileOverview()).toBe(true);
|
||||
expect(loadOverviewApp({ isSoloWindow: true }).shouldUseMobileOverview()).toBe(false);
|
||||
expect(
|
||||
loadOverviewApp({
|
||||
loadAppSettingsFromStorage: () => ({ mobileOverviewEnabled: false }),
|
||||
}).shouldUseMobileOverview()
|
||||
).toBe(false);
|
||||
// An unset value must read as ON: phones that already have saved settings
|
||||
// from before this feature existed have no key for it.
|
||||
expect(loadOverviewApp({ loadAppSettingsFromStorage: () => ({ skin: 'og' }) }).shouldUseMobileOverview()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview wiring', () => {
|
||||
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
|
||||
const moduleSrc = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8');
|
||||
|
||||
it('speaks the same status language as the session tabs', () => {
|
||||
// A session that is fine reads green on the tabs; anything else here would
|
||||
// mean two meanings for one color on the same screen.
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-dot--idle\s*\{\s*background:\s*var\(--green\)/);
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-dot--working\s*\{[^}]*var\(--green\)[^}]*animation:\s*pulse/);
|
||||
// Waiting-for-input blinks yellow, asked-a-question blinks red, same as
|
||||
// tab-alert-idle / tab-alert-action.
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-row--waiting\s*\{[^}]*animation:\s*mobile-overview-blink-yellow/);
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-row--needs\s*\{[^}]*animation:\s*mobile-overview-blink-red/);
|
||||
expect(mobileCss).toContain('@keyframes mobile-overview-blink-red');
|
||||
expect(mobileCss).toContain('@keyframes mobile-overview-blink-yellow');
|
||||
// The alert must survive reduced-motion as a held color, not vanish.
|
||||
expect(mobileCss).toMatch(/prefers-reduced-motion[^}]*\}[\s\S]*?\.mobile-overview-row--needs/);
|
||||
});
|
||||
|
||||
it('reuses the toolbar Run button classes instead of its own palette', () => {
|
||||
// The per-backend gradient lives in styles.css keyed on
|
||||
// `.btn-toolbar.btn-run.mode-<backend>` (and light skins override exactly
|
||||
// those); carrying the same classes keeps both Run buttons identical.
|
||||
expect(moduleSrc).toContain('btn-toolbar btn-run mode-');
|
||||
expect(moduleSrc).toContain('btn-toolbar btn-run-gear mode-');
|
||||
// The two button rules (not the dropdown below them) must set no color at
|
||||
// all, or they would win over the mode gradient.
|
||||
const buttonRules = mobileCss.match(/\.mobile-overview-run(-caret)?\s*\{[^}]*\}/g) || [];
|
||||
expect(buttonRules.length).toBe(2);
|
||||
for (const rule of buttonRules) {
|
||||
expect(rule).not.toMatch(/\b(background|color)\s*:/);
|
||||
}
|
||||
});
|
||||
|
||||
it('ships the container hidden and loads the module', () => {
|
||||
expect(html).toMatch(/<div class="mobile-overview" id="mobileOverview" hidden><\/div>/);
|
||||
expect(html).toContain('<script defer src="mobile-overview.js"></script>');
|
||||
});
|
||||
|
||||
it('never gives .mobile-overview a bare display rule', () => {
|
||||
// Desktop does not load mobile.css at all, so the [hidden] attribute is the
|
||||
// only thing keeping the overview off desktop. A bare
|
||||
// `.mobile-overview { display: … }` rule would beat the UA [hidden] rule.
|
||||
const bareDisplay = /\.mobile-overview\s*\{[^}]*display\s*:/;
|
||||
expect(bareDisplay.test(mobileCss)).toBe(false);
|
||||
expect(mobileCss).toContain('.mobile-overview.visible {');
|
||||
});
|
||||
|
||||
it('styles the overview from skin tokens rather than hardcoded colors', () => {
|
||||
// Skins re-point the :root tokens, so a hex literal here is a rule that
|
||||
// silently stays dark on the four light skins.
|
||||
const rules = mobileCss.match(/\.mobile-overview[^{}]*\{[^}]*\}/g) || [];
|
||||
expect(rules.length).toBeGreaterThan(10);
|
||||
const hardcoded = rules.flatMap((rule) => rule.match(/:\s*#[0-9a-f]{3,8}\b/gi) || []);
|
||||
expect(hardcoded).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
function modeButtons(menu: any): string[] {
|
||||
return menu.children.filter((c: any) => c.dataset.moAction === 'run-mode').map((c: any) => c.dataset.moMode);
|
||||
}
|
||||
|
||||
// #201 gated the toolbar's #runModeMenu on isCliAvailable(); this phone-only
|
||||
// picker (MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu) is a
|
||||
// separate, hardcoded duplicate of that menu rather than a shared render, so
|
||||
// it silently offered every backend regardless of what the server reported.
|
||||
it('hides run modes the server reports as unavailable, keeps shell always', () => {
|
||||
const app = loadOverviewApp({
|
||||
runMode: 'claude',
|
||||
isCliAvailable: (tool: string) => tool === 'claude',
|
||||
});
|
||||
const menu = app._buildMobileOverviewRunMenu();
|
||||
expect(modeButtons(menu)).toEqual(['claude', 'shell']);
|
||||
});
|
||||
|
||||
it('shows every mode when every CLI is available', () => {
|
||||
const app = loadOverviewApp({
|
||||
runMode: 'claude',
|
||||
isCliAvailable: () => true,
|
||||
});
|
||||
const menu = app._buildMobileOverviewRunMenu();
|
||||
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']);
|
||||
});
|
||||
|
||||
it('gates every mode the picker actually offers', () => {
|
||||
// Catches a new backend being added to MOBILE_OVERVIEW_RUN_MODES without
|
||||
// being gated — the same class of bug that let this list drift from the
|
||||
// toolbar menu's gating in the first place.
|
||||
const src = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8');
|
||||
const modesBlock = src.slice(
|
||||
src.indexOf('const MOBILE_OVERVIEW_RUN_MODES'),
|
||||
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
|
||||
);
|
||||
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
|
||||
expect(offered).toContain('antigravity');
|
||||
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
|
||||
const gate = fn.slice(0, fn.indexOf('const header'));
|
||||
expect(gate).toContain('isCliAvailable');
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ export class MockSession extends EventEmitter {
|
||||
ralphTracker: null = null;
|
||||
writeBuffer: string[] = [];
|
||||
terminalBuffer: string = '';
|
||||
/** Mirrors Session.lastSubmitAt — the response viewer credits history entries by it. */
|
||||
lastSubmitAt: number = 0;
|
||||
|
||||
private _muxName: string | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Regression tests for the node-pty spawn-helper repair (issues #6, #204).
|
||||
*
|
||||
* node-pty@1.1.0 publishes `prebuilds/darwin-<arch>/spawn-helper` with mode 0644,
|
||||
* so on macOS every PTY spawn dies with `posix_spawnp failed.`. These tests pin
|
||||
* the two things the old fix got wrong: it looked ONLY in `build/Release` (which
|
||||
* does not exist on macOS, where the prebuilt binary is used), and it never
|
||||
* checked whether the repair actually worked.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
isSpawnHelperFailure,
|
||||
listSpawnHelpers,
|
||||
repairSpawnHelperPermissions,
|
||||
spawnPtyWithHelperRepair,
|
||||
resetSpawnHelperRepairState,
|
||||
SPAWN_HELPER_FIX_HINT,
|
||||
} from '../src/utils/node-pty-repair.js';
|
||||
|
||||
/** Builds a fake node-pty tree; each entry is a directory that gets a spawn-helper. */
|
||||
function makeFakePtyDir(helpers: Array<{ dir: string; mode: number }>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-node-pty-'));
|
||||
for (const { dir, mode } of helpers) {
|
||||
const full = join(root, dir);
|
||||
mkdirSync(full, { recursive: true });
|
||||
const helper = join(full, 'spawn-helper');
|
||||
writeFileSync(helper, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(helper, mode);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function modeOf(path: string): number {
|
||||
return statSync(path).mode & 0o777;
|
||||
}
|
||||
|
||||
describe('node-pty spawn-helper repair', () => {
|
||||
const created: string[] = [];
|
||||
|
||||
beforeEach(() => resetSpawnHelperRepairState());
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture(helpers: Array<{ dir: string; mode: number }>): string {
|
||||
const root = makeFakePtyDir(helpers);
|
||||
created.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('isSpawnHelperFailure', () => {
|
||||
it('matches the native error node-pty throws on macOS', () => {
|
||||
expect(isSpawnHelperFailure(new Error('posix_spawnp failed.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches errors that name the helper directly', () => {
|
||||
expect(isSpawnHelperFailure(new Error('ENOENT: no such file, spawn-helper'))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated spawn failures', () => {
|
||||
expect(isSpawnHelperFailure(new Error('cwd does not exist'))).toBe(false);
|
||||
expect(isSpawnHelperFailure(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSpawnHelpers', () => {
|
||||
it('finds the prebuilt helper, which is the ONLY one that exists on macOS', () => {
|
||||
// A stock macOS install has no build/ directory at all: node-pty ships a
|
||||
// darwin prebuild, so node-gyp never runs.
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
expect(listSpawnHelpers(root)).toEqual([join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')]);
|
||||
});
|
||||
|
||||
it('finds helpers across build/Release, build/Debug and every prebuilds arch', () => {
|
||||
const root = fixture([
|
||||
{ dir: 'build/Release', mode: 0o755 },
|
||||
{ dir: 'build/Debug', mode: 0o644 },
|
||||
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
|
||||
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
|
||||
]);
|
||||
expect(listSpawnHelpers(root).sort()).toEqual(
|
||||
[
|
||||
join(root, 'build', 'Release', 'spawn-helper'),
|
||||
join(root, 'build', 'Debug', 'spawn-helper'),
|
||||
join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper'),
|
||||
join(root, 'prebuilds', 'darwin-x64', 'spawn-helper'),
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('returns nothing for a Linux install, which has no spawn-helper at all', () => {
|
||||
const root = fixture([]);
|
||||
mkdirSync(join(root, 'build', 'Release'), { recursive: true });
|
||||
writeFileSync(join(root, 'build', 'Release', 'pty.node'), 'stub');
|
||||
expect(listSpawnHelpers(root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairSpawnHelperPermissions', () => {
|
||||
it('adds the execute bit to the 0644 prebuilt helper', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
|
||||
const repaired = repairSpawnHelperPermissions(root);
|
||||
|
||||
expect(repaired).toEqual([helper]);
|
||||
expect(modeOf(helper) & 0o111).toBe(0o111);
|
||||
});
|
||||
|
||||
it('is a no-op on an already-executable helper', () => {
|
||||
const root = fixture([{ dir: 'build/Release', mode: 0o755 }]);
|
||||
expect(repairSpawnHelperPermissions(root)).toEqual([]);
|
||||
expect(modeOf(join(root, 'build', 'Release', 'spawn-helper'))).toBe(0o755);
|
||||
});
|
||||
|
||||
it('repairs every copy, not just the first one found', () => {
|
||||
const root = fixture([
|
||||
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
|
||||
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
|
||||
]);
|
||||
expect(repairSpawnHelperPermissions(root)).toHaveLength(2);
|
||||
for (const arch of ['darwin-arm64', 'darwin-x64']) {
|
||||
expect(modeOf(join(root, 'prebuilds', arch, 'spawn-helper')) & 0o111).toBe(0o111);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the non-execute permission bits it was given', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o640 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
repairSpawnHelperPermissions(root);
|
||||
expect(modeOf(helper)).toBe(0o755 | 0o640);
|
||||
});
|
||||
|
||||
it('returns nothing when node-pty has no helper to repair', () => {
|
||||
expect(repairSpawnHelperPermissions(fixture([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnPtyWithHelperRepair', () => {
|
||||
it('passes the spawn result straight through when nothing is wrong', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o755 }]);
|
||||
expect(spawnPtyWithHelperRepair(() => 'pty', root)).toBe('pty');
|
||||
});
|
||||
|
||||
it('repairs and retries once after a posix_spawnp failure', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
|
||||
let attempts = 0;
|
||||
const result = spawnPtyWithHelperRepair(() => {
|
||||
attempts++;
|
||||
// Mirror the real failure: node-pty only throws while the helper is 0644.
|
||||
if ((modeOf(helper) & 0o111) !== 0o111) throw new Error('posix_spawnp failed.');
|
||||
return 'pty';
|
||||
}, root);
|
||||
|
||||
expect(result).toBe('pty');
|
||||
expect(attempts).toBe(2);
|
||||
expect(modeOf(helper) & 0o111).toBe(0o111);
|
||||
});
|
||||
|
||||
it('rethrows unrelated errors untouched, without chmodding anything', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
|
||||
expect(() =>
|
||||
spawnPtyWithHelperRepair(() => {
|
||||
throw new Error('cwd does not exist');
|
||||
}, root)
|
||||
).toThrow('cwd does not exist');
|
||||
|
||||
expect(modeOf(helper)).toBe(0o644);
|
||||
});
|
||||
|
||||
it('surfaces the manual fix command when the retry still fails', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
|
||||
expect(() =>
|
||||
spawnPtyWithHelperRepair(() => {
|
||||
throw new Error('posix_spawnp failed.');
|
||||
}, root)
|
||||
).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
});
|
||||
|
||||
it('surfaces the fix command when there is no helper to repair', () => {
|
||||
const root = fixture([]);
|
||||
|
||||
expect(() =>
|
||||
spawnPtyWithHelperRepair(() => {
|
||||
throw new Error('posix_spawnp failed.');
|
||||
}, root)
|
||||
).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
});
|
||||
|
||||
it('does not chmod-storm: only the first failure triggers a repair attempt', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const boom = () => {
|
||||
throw new Error('posix_spawnp failed.');
|
||||
};
|
||||
|
||||
expect(() => spawnPtyWithHelperRepair(boom, root)).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
|
||||
// Second call: repair already attempted, so it fails fast with the hint and
|
||||
// never re-walks the tree.
|
||||
const untouched = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
expect(() => spawnPtyWithHelperRepair(boom, untouched)).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
expect(modeOf(join(untouched, 'prebuilds', 'darwin-arm64', 'spawn-helper'))).toBe(0o644);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(new URL('../src/web/public/notification-manager.js', import.meta.url), 'utf8');
|
||||
|
||||
type EventPreference = {
|
||||
enabled: boolean;
|
||||
browser: boolean;
|
||||
audio: boolean;
|
||||
push: boolean;
|
||||
};
|
||||
|
||||
type NotificationPreferences = {
|
||||
enabled: boolean;
|
||||
eventTypes: Record<string, EventPreference>;
|
||||
_version: number;
|
||||
};
|
||||
|
||||
type Manager = {
|
||||
preferences: NotificationPreferences;
|
||||
notifications: unknown[];
|
||||
getStorageKey: () => string;
|
||||
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
|
||||
notify: (notification: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
const openWindows: JSDOM[] = [];
|
||||
|
||||
function loadManager(
|
||||
saved?: Record<string, unknown>,
|
||||
device: { deviceType?: string; handheld?: boolean } = {}
|
||||
): { dom: JSDOM; manager: Manager } {
|
||||
const dom = new JSDOM(
|
||||
'<!doctype html><body><span id="notifBadge"></span><div id="notifList"></div><div id="notifEmpty"></div></body>',
|
||||
{
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
}
|
||||
);
|
||||
openWindows.push(dom);
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & {
|
||||
MobileDetection: {
|
||||
getDeviceType: () => string;
|
||||
isHandheldDevice?: () => boolean;
|
||||
};
|
||||
STUCK_THRESHOLD_DEFAULT_MS: number;
|
||||
GROUPING_TIMEOUT_MS: number;
|
||||
NOTIFICATION_LIST_CAP: number;
|
||||
};
|
||||
win.MobileDetection = {
|
||||
getDeviceType: () => device.deviceType ?? 'desktop',
|
||||
...(typeof device.handheld === 'boolean' ? { isHandheldDevice: () => device.handheld === true } : {}),
|
||||
};
|
||||
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
|
||||
win.GROUPING_TIMEOUT_MS = 5_000;
|
||||
win.NOTIFICATION_LIST_CAP = 100;
|
||||
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof requestAnimationFrame;
|
||||
|
||||
if (saved) {
|
||||
win.localStorage.setItem('codeman-notification-prefs', JSON.stringify(saved));
|
||||
}
|
||||
|
||||
win.eval(`
|
||||
${SOURCE}
|
||||
window.__testNotificationManager = NotificationManager;
|
||||
`);
|
||||
const NotificationManager = (
|
||||
win as unknown as {
|
||||
__testNotificationManager: new (app: { sessions: Map<unknown, unknown> }) => Manager;
|
||||
}
|
||||
).__testNotificationManager;
|
||||
const manager = new NotificationManager({ sessions: new Map() }) as Manager;
|
||||
return { dom, manager };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dom of openWindows.splice(0)) dom.window.close();
|
||||
});
|
||||
|
||||
describe('notification noise defaults', () => {
|
||||
it('keeps response-complete and team lifecycle drawer entries opt-in', () => {
|
||||
const { manager } = loadManager();
|
||||
expect(manager.preferences.eventTypes.stop.enabled).toBe(false);
|
||||
|
||||
for (const category of ['hook-stop', 'hook-teammate-idle', 'hook-task-completed']) {
|
||||
manager.notify({
|
||||
urgency: 'info',
|
||||
category,
|
||||
sessionId: 'session-1',
|
||||
sessionName: 'session',
|
||||
title: category,
|
||||
message: category,
|
||||
});
|
||||
}
|
||||
|
||||
expect(manager.notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('migrates the old drawer-only Stop default but preserves explicit delivery', () => {
|
||||
const quietV4 = {
|
||||
enabled: true,
|
||||
eventTypes: {
|
||||
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||
},
|
||||
_version: 4,
|
||||
};
|
||||
const { manager: quietManager } = loadManager(quietV4);
|
||||
expect(quietManager.preferences.eventTypes.stop.enabled).toBe(false);
|
||||
expect(quietManager.preferences._version).toBe(5);
|
||||
|
||||
const browserV4 = {
|
||||
enabled: true,
|
||||
eventTypes: {
|
||||
stop: { enabled: true, browser: true, audio: false, push: false },
|
||||
},
|
||||
_version: 4,
|
||||
};
|
||||
const { manager: browserManager } = loadManager(browserV4);
|
||||
expect(browserManager.preferences.eventTypes.stop.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes server-hydrated v4 preferences through the same quiet migration', () => {
|
||||
const { manager } = loadManager();
|
||||
manager.preferences = manager.normalizePreferences({
|
||||
enabled: true,
|
||||
eventTypes: {
|
||||
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||
},
|
||||
_version: 4,
|
||||
});
|
||||
|
||||
expect(manager.preferences.eventTypes.stop.enabled).toBe(false);
|
||||
expect(manager.preferences._version).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps mobile notification defaults and storage on an unfolded handheld', () => {
|
||||
const { manager } = loadManager(undefined, {
|
||||
deviceType: 'desktop',
|
||||
handheld: true,
|
||||
});
|
||||
|
||||
expect(manager.preferences.enabled).toBe(false);
|
||||
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
|
||||
});
|
||||
});
|
||||
@@ -55,10 +55,15 @@ describe('remote-hosts domain', () => {
|
||||
});
|
||||
|
||||
it('returns safe mode defaults and remote display values', () => {
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec "${SHELL:-/bin/sh}" -i -l');
|
||||
// Routed through an interactive login shell so per-user PATH entries (e.g.
|
||||
// ~/.local/bin, ~/.opencode/bin) resolve — a bare `exec codex` sees only
|
||||
// sshd's minimal default PATH and fails with "command not found".
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'codex\'');
|
||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe(
|
||||
'exec "${SHELL:-/bin/sh}" -i -l -c \'claude --dangerously-skip-permissions\''
|
||||
);
|
||||
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
||||
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
||||
'aamer@box.local:/opt/work'
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec bash -l`;
|
||||
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
@@ -159,6 +159,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
'set -s escape-time 0',
|
||||
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
// #210 — keep a CRASHED pane for diagnosis. `failed` (not `on`, which would
|
||||
// also strand a pane after a clean `exit`), and LAST because tmux aborts the
|
||||
// remaining commands of a `\;` chain on error and `failed` needs tmux >= 3.2.
|
||||
`set -t ${remoteName} remain-on-exit failed`,
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
|
||||
@@ -12,6 +12,40 @@
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js';
|
||||
import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js';
|
||||
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
|
||||
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
|
||||
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
|
||||
// renderIndexHtml probes the real PATH for every CLI, which would make the
|
||||
// assertions below depend on whatever happens to be installed on the machine
|
||||
// running the suite. Default them all to "not installed" and opt in per test.
|
||||
vi.mock('../src/utils/claude-cli-resolver.js', () => ({
|
||||
isClaudeAvailable: vi.fn(() => false),
|
||||
findClaudeDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/opencode-cli-resolver.js', () => ({
|
||||
isOpenCodeAvailable: vi.fn(() => false),
|
||||
resolveOpenCodeDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/codex-cli-resolver.js', () => ({
|
||||
isCodexAvailable: vi.fn(() => false),
|
||||
resolveCodexDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/gemini-cli-resolver.js', () => ({
|
||||
isGeminiAvailable: vi.fn(() => false),
|
||||
resolveGeminiDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({
|
||||
isAntigravityAvailable: vi.fn(() => false),
|
||||
resolveAntigravityDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
isCloudflaredAvailable: vi.fn(() => false),
|
||||
resolveCloudflaredPath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const TEMPLATE = [
|
||||
'<head>',
|
||||
@@ -86,6 +120,55 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
expect(html).toContain('gesture-codeman.js');
|
||||
});
|
||||
|
||||
it('reports every tool the welcome buttons, run menu and Codex tab gate on', async () => {
|
||||
vi.mocked(isClaudeAvailable).mockReturnValue(true);
|
||||
vi.mocked(isOpenCodeAvailable).mockReturnValue(false);
|
||||
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGeminiAvailable).mockReturnValue(false);
|
||||
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
|
||||
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
|
||||
// Every key must be PRESENT, not merely truthy where installed: the client
|
||||
// treats a missing key as available, so a dropped key silently un-gates.
|
||||
expect(flags).toEqual({
|
||||
claude: true,
|
||||
opencode: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
cloudflared: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('still emits the object when nothing at all is installed', async () => {
|
||||
// The all-false case is the one that matters most and the easiest to get
|
||||
// wrong by only injecting when something resolves.
|
||||
for (const probe of [
|
||||
isClaudeAvailable,
|
||||
isOpenCodeAvailable,
|
||||
isCodexAvailable,
|
||||
isGeminiAvailable,
|
||||
isAntigravityAvailable,
|
||||
isCloudflaredAvailable,
|
||||
]) {
|
||||
vi.mocked(probe).mockReturnValue(false);
|
||||
}
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
expect(html).toContain('window.__codemanCliAvailable=');
|
||||
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
|
||||
expect(Object.values(flags).every((v) => v === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
|
||||
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server, 'sess-123');
|
||||
expect(html).not.toContain('__codemanCliAvailable');
|
||||
});
|
||||
|
||||
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
||||
delete process.env.CODEMAN_GESTURE;
|
||||
const { server } = makeServer({ gestureControlEnabled: true });
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @fileoverview File Viewer edit mode — read-for-edit (`edit=1`) and
|
||||
* `PUT /api/sessions/:id/file-content` (issue #212).
|
||||
*
|
||||
* Deliberately does NOT mock node:fs — every case runs against a real temp
|
||||
* workspace so the confinement (realpath + workspace boundary), the symlink
|
||||
* behavior, the atomic temp+rename write, and mode preservation are exercised
|
||||
* for real, not against a mock's assumptions.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
chmodSync,
|
||||
statSync,
|
||||
realpathSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../../src/config/file-editing.js';
|
||||
|
||||
function sha256(data: string | Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
describe('file viewer edit mode (real fs)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let workDir: string;
|
||||
let outsideDir: string;
|
||||
const sessionId = 'test-session-1';
|
||||
|
||||
const putFile = (path: string, body: Record<string, unknown>) =>
|
||||
harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${sessionId}/file-content`,
|
||||
payload: { path, ...body },
|
||||
});
|
||||
|
||||
const getEdit = (path: string) =>
|
||||
harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(path)}&edit=1`,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
// realpath: on some hosts tmpdir() contains a symlinked component, which
|
||||
// would make validateSessionFilePath's relative() check misfire.
|
||||
workDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-edit-ws-')));
|
||||
outsideDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-edit-out-')));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
rmSync(outsideDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ========== GET ?edit=1 ==========
|
||||
|
||||
describe('GET /api/sessions/:id/file-content?edit=1', () => {
|
||||
it('returns the FULL content (never truncated) with hash and eol', async () => {
|
||||
const content = Array.from({ length: 800 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||
writeFileSync(join(workDir, 'long.md'), content);
|
||||
|
||||
const res = await getEdit('long.md');
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.content).toBe(content);
|
||||
expect(body.data.truncated).toBe(false);
|
||||
expect(body.data.totalLines).toBe(800);
|
||||
expect(body.data.editable).toBe(true);
|
||||
expect(body.data.hash).toBe(sha256(content));
|
||||
expect(body.data.eol).toBe('lf');
|
||||
});
|
||||
|
||||
it('reports crlf for a CRLF file', async () => {
|
||||
writeFileSync(join(workDir, 'dos.txt'), 'a\r\nb\r\nc');
|
||||
const res = await getEdit('dos.txt');
|
||||
expect(res.json().data.eol).toBe('crlf');
|
||||
});
|
||||
|
||||
it('413s above MAX_EDITABLE_BYTES instead of truncating', async () => {
|
||||
writeFileSync(join(workDir, 'big.log'), 'x'.repeat(MAX_EDITABLE_BYTES + 1));
|
||||
const res = await getEdit('big.log');
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('400s for a non-allowlisted extension', async () => {
|
||||
writeFileSync(join(workDir, 'data.xyz'), 'text');
|
||||
const res = await getEdit('data.xyz');
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('400s for binary content even with a text extension', async () => {
|
||||
writeFileSync(join(workDir, 'fake.txt'), Buffer.from([0x68, 0x00, 0x69]));
|
||||
const res = await getEdit('fake.txt');
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain read editable flag', () => {
|
||||
it('advertises editable:true for an editable text file', async () => {
|
||||
writeFileSync(join(workDir, 'notes.md'), 'hello');
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.md`,
|
||||
});
|
||||
expect(res.json().data.editable).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises editable:false for a non-allowlisted extension', async () => {
|
||||
writeFileSync(join(workDir, 'schema.xsd'), '<xml/>');
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=schema.xsd`,
|
||||
});
|
||||
const data = res.json().data;
|
||||
expect(data.content).toBeDefined();
|
||||
expect(data.editable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT ==========
|
||||
|
||||
describe('PUT /api/sessions/:id/file-content', () => {
|
||||
it('happy path: writes the bytes, returns new hash, leaves no temp files', async () => {
|
||||
const original = 'line one\nline two\n';
|
||||
writeFileSync(join(workDir, 'notes.md'), original);
|
||||
|
||||
const updated = 'line one EDITED\nline two\n';
|
||||
const res = await putFile('notes.md', { content: updated, baseHash: sha256(original) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.hash).toBe(sha256(updated));
|
||||
expect(body.data.eol).toBe('lf');
|
||||
expect(body.data.size).toBe(Buffer.byteLength(updated));
|
||||
|
||||
expect(readFileSync(join(workDir, 'notes.md'), 'utf8')).toBe(updated);
|
||||
const leftovers = readdirSync(workDir).filter((n) => n.includes('codeman-tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('404s on ../ traversal without touching the outside file', async () => {
|
||||
const target = join(outsideDir, 'victim.md');
|
||||
writeFileSync(target, 'safe');
|
||||
// Build a relative path that resolves outside the workspace.
|
||||
const traversal = `..${target.startsWith('/') ? target : `/${target}`}`;
|
||||
const res = await putFile(traversal, { content: 'pwned', baseHash: sha256('safe') });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(target, 'utf8')).toBe('safe');
|
||||
});
|
||||
|
||||
it('404s on an absolute path outside the workspace', async () => {
|
||||
const target = join(outsideDir, 'victim2.md');
|
||||
writeFileSync(target, 'safe');
|
||||
const res = await putFile(target, { content: 'pwned', baseHash: sha256('safe') });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(target, 'utf8')).toBe('safe');
|
||||
});
|
||||
|
||||
it('404s a symlink pointing outside the workspace and never follows it', async () => {
|
||||
const target = join(outsideDir, 'secret.md');
|
||||
writeFileSync(target, 'outside');
|
||||
symlinkSync(target, join(workDir, 'sneaky.md'));
|
||||
|
||||
const res = await putFile('sneaky.md', { content: 'pwned', baseHash: sha256('outside') });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(target, 'utf8')).toBe('outside');
|
||||
});
|
||||
|
||||
it('writes THROUGH a symlink whose target is inside the workspace', async () => {
|
||||
writeFileSync(join(workDir, 'real.md'), 'original');
|
||||
symlinkSync(join(workDir, 'real.md'), join(workDir, 'alias.md'));
|
||||
|
||||
const res = await putFile('alias.md', { content: 'via alias', baseHash: sha256('original') });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readFileSync(join(workDir, 'real.md'), 'utf8')).toBe('via alias');
|
||||
});
|
||||
|
||||
it('400s a non-allowlisted extension', async () => {
|
||||
writeFileSync(join(workDir, 'blob.xyz'), 'text');
|
||||
const res = await putFile('blob.xyz', { content: 'nope', baseHash: sha256('text') });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'blob.xyz'), 'utf8')).toBe('text');
|
||||
});
|
||||
|
||||
it('403s inside .git even for an allowlisted-looking name', async () => {
|
||||
mkdirSync(join(workDir, '.git'));
|
||||
writeFileSync(join(workDir, '.git', 'config.ini'), '[core]');
|
||||
const res = await putFile('.git/config.ini', { content: 'x', baseHash: sha256('[core]') });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a .env file (allowlist first, sensitive-path as backstop)', async () => {
|
||||
writeFileSync(join(workDir, '.env'), 'SECRET=1');
|
||||
const res = await putFile('.env', { content: 'SECRET=2', baseHash: sha256('SECRET=1') });
|
||||
expect([400, 403]).toContain(res.statusCode);
|
||||
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('SECRET=1');
|
||||
});
|
||||
|
||||
it('400s when the current file contains a NUL byte', async () => {
|
||||
writeFileSync(join(workDir, 'weird.txt'), Buffer.from([0x61, 0x00, 0x62]));
|
||||
const res = await putFile('weird.txt', { content: 'ab', baseHash: sha256(Buffer.from([0x61, 0x00, 0x62])) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('400s when the current file is not valid UTF-8 (latin-1)', async () => {
|
||||
const latin1 = Buffer.from('caf\xe9 au lait', 'latin1');
|
||||
writeFileSync(join(workDir, 'legacy.txt'), latin1);
|
||||
const res = await putFile('legacy.txt', { content: 'cafe au lait', baseHash: sha256(latin1) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'legacy.txt'))).toEqual(latin1);
|
||||
});
|
||||
|
||||
it('409s on a stale baseHash and succeeds with force:true', async () => {
|
||||
writeFileSync(join(workDir, 'contested.md'), 'agent version');
|
||||
|
||||
const res = await putFile('contested.md', { content: 'my version', baseHash: sha256('older version') });
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().errorCode).toBe('CONFLICT');
|
||||
expect(readFileSync(join(workDir, 'contested.md'), 'utf8')).toBe('agent version');
|
||||
|
||||
const forced = await putFile('contested.md', {
|
||||
content: 'my version',
|
||||
baseHash: sha256('older version'),
|
||||
force: true,
|
||||
});
|
||||
expect(forced.statusCode).toBe(200);
|
||||
expect(readFileSync(join(workDir, 'contested.md'), 'utf8')).toBe('my version');
|
||||
});
|
||||
|
||||
it('rejects oversized ASCII content at the schema pre-filter (400)', async () => {
|
||||
writeFileSync(join(workDir, 'small.md'), 'ok');
|
||||
const res = await putFile('small.md', {
|
||||
content: 'x'.repeat(MAX_EDITABLE_BYTES + 1),
|
||||
baseHash: sha256('ok'),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'small.md'), 'utf8')).toBe('ok');
|
||||
});
|
||||
|
||||
it('413s multibyte content that passes the code-unit pre-filter but exceeds the byte cap', async () => {
|
||||
writeFileSync(join(workDir, 'small.md'), 'ok');
|
||||
// '€' is 1 UTF-16 code unit but 3 UTF-8 bytes: 200k units (< 512Ki cap)
|
||||
// becomes ~586KB on disk, so only the handler's byteLength check catches it.
|
||||
const res = await putFile('small.md', {
|
||||
content: '€'.repeat(200_000),
|
||||
baseHash: sha256('ok'),
|
||||
});
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(readFileSync(join(workDir, 'small.md'), 'utf8')).toBe('ok');
|
||||
});
|
||||
|
||||
it('404s a missing file and creates nothing (edit-in-place only)', async () => {
|
||||
const res = await putFile('brand-new.md', { content: 'hello', baseHash: sha256('hello') });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(existsSync(join(workDir, 'brand-new.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a malformed baseHash at the schema layer', async () => {
|
||||
writeFileSync(join(workDir, 'a.md'), 'x');
|
||||
const res = await putFile('a.md', { content: 'y', baseHash: 'not-a-hash' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('preserves CRLF line endings across a textarea-normalized save', async () => {
|
||||
const original = 'first\r\nsecond\r\nthird';
|
||||
writeFileSync(join(workDir, 'dos.txt'), original);
|
||||
|
||||
// Client sends LF-normalized content + the eol it was told at load time.
|
||||
const res = await putFile('dos.txt', {
|
||||
content: 'first\nsecond EDITED\nthird',
|
||||
baseHash: sha256(original),
|
||||
eol: 'crlf',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readFileSync(join(workDir, 'dos.txt'), 'utf8')).toBe('first\r\nsecond EDITED\r\nthird');
|
||||
});
|
||||
|
||||
it('re-applies the original EOL even when the client omits eol', async () => {
|
||||
const original = 'a\r\nb';
|
||||
writeFileSync(join(workDir, 'implicit.txt'), original);
|
||||
const res = await putFile('implicit.txt', { content: 'a\nb\nc', baseHash: sha256(original) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readFileSync(join(workDir, 'implicit.txt'), 'utf8')).toBe('a\r\nb\r\nc');
|
||||
});
|
||||
|
||||
it('preserves the file mode across the temp+rename', async () => {
|
||||
const p = join(workDir, 'script.sh');
|
||||
writeFileSync(p, '#!/bin/sh\necho hi\n');
|
||||
chmodSync(p, 0o750);
|
||||
|
||||
const res = await putFile('script.sh', {
|
||||
content: '#!/bin/sh\necho bye\n',
|
||||
baseHash: sha256('#!/bin/sh\necho hi\n'),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(statSync(p).mode & 0o777).toBe(0o750);
|
||||
});
|
||||
|
||||
it('rejects unknown body keys (.strict() schema)', async () => {
|
||||
writeFileSync(join(workDir, 'a.md'), 'x');
|
||||
const res = await putFile('a.md', { content: 'y', baseHash: sha256('x'), evil: true });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== multi-user scoping ==========
|
||||
|
||||
describe('multi-user ownership', () => {
|
||||
it("404s a non-admin writing to a session they don't own", async () => {
|
||||
const prev = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
try {
|
||||
const scoped = await createRouteTestHarness(registerFileRoutes, {
|
||||
authUser: { username: 'mallory', role: 'user' },
|
||||
});
|
||||
scoped.ctx._session.workingDir = workDir;
|
||||
writeFileSync(join(workDir, 'owned.md'), 'admin file');
|
||||
|
||||
const res = await scoped.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${sessionId}/file-content`,
|
||||
payload: { path: 'owned.md', content: 'stolen', baseHash: sha256('admin file') },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(workDir, 'owned.md'), 'utf8')).toBe('admin file');
|
||||
await scoped.app.close();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
@@ -176,3 +176,131 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Which conversation a pane is on is decided by the pane's own Enter, not by
|
||||
* "newest entry for this cwd" — a cwd is shared with every other tab on it,
|
||||
* with tabs long since closed, and with any plain `claude` the user runs in
|
||||
* their own terminal.
|
||||
*/
|
||||
describe('GET /api/sessions/:id/last-response (claude conversation pinning)', () => {
|
||||
let harness: LocalHarness;
|
||||
let testHome: string;
|
||||
let previousHome: string | undefined;
|
||||
const WORKDIR = '/workspace';
|
||||
const NOW = 1_770_000_000_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-pin-'));
|
||||
previousHome = process.env.HOME;
|
||||
process.env.HOME = testHome;
|
||||
harness = await createEnvelopeHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
/** A transcript whose only assistant turn is `text`, stamped at `mtimeMs`. */
|
||||
function writeTranscript(conversationId: string, text: string, mtimeMs: number): void {
|
||||
const projectDir = join(testHome, '.claude', 'projects', '-workspace');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const path = join(projectDir, `${conversationId}.jsonl`);
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(mtimeMs).toISOString(),
|
||||
message: { content: [{ type: 'text', text }] },
|
||||
})
|
||||
);
|
||||
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000);
|
||||
}
|
||||
|
||||
function writeHistory(entries: Array<{ sessionId: string; timestamp: number; project?: string }>): void {
|
||||
const claudeDir = join(testHome, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'history.jsonl'),
|
||||
entries
|
||||
.map((entry) => JSON.stringify({ display: 'prompt', project: entry.project ?? WORKDIR, ...entry }))
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
/** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */
|
||||
function addPane(id: string, conversationId: string, lastSubmitAt: number) {
|
||||
const base = harness.ctx._session;
|
||||
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
|
||||
claudeSessionId: string;
|
||||
lastSubmitAt: number;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
Object.assign(pane, base, { id, mode: 'claude', workingDir: WORKDIR, docker: undefined });
|
||||
pane.claudeSessionId = conversationId;
|
||||
pane.lastSubmitAt = lastSubmitAt;
|
||||
pane.adoptClaudeSessionId = vi.fn((newId: string) => {
|
||||
pane.claudeSessionId = newId;
|
||||
});
|
||||
harness.ctx.sessions.set(id, pane);
|
||||
return pane;
|
||||
}
|
||||
|
||||
async function getLastResponse(sessionId: string) {
|
||||
const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/last-response` });
|
||||
return JSON.parse(response.body).data as { text: string };
|
||||
}
|
||||
|
||||
it('does not adopt a conversation from another claude process sharing the cwd', async () => {
|
||||
// The pane typed hours ago; a `claude` running in the user's own terminal
|
||||
// is the newest thing in this cwd. Before this fix the viewer followed it.
|
||||
const pane = addPane('pane-1', 'pane-conversation', NOW - 6 * 3600_000);
|
||||
writeTranscript('pane-conversation', 'my own answer', NOW - 6 * 3600_000);
|
||||
writeTranscript('someone-elses-conversation', 'a stranger answer', NOW);
|
||||
writeHistory([{ sessionId: 'someone-elses-conversation', timestamp: NOW }]);
|
||||
|
||||
expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) });
|
||||
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows /clear onto the new conversation the pane submitted into', async () => {
|
||||
const pane = addPane('pane-1', 'before-clear', NOW);
|
||||
writeTranscript('before-clear', 'answer before clear', NOW - 60_000);
|
||||
writeTranscript('after-clear', 'answer after clear', NOW + 500);
|
||||
writeHistory([{ sessionId: 'after-clear', timestamp: NOW + 120 }]);
|
||||
|
||||
expect(await getLastResponse('pane-1')).toEqual({ text: 'answer after clear', timestamp: expect.any(String) });
|
||||
expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear');
|
||||
});
|
||||
|
||||
it('stays put when the pane has never submitted through Codeman', async () => {
|
||||
const pane = addPane('pane-1', 'pane-conversation', 0);
|
||||
writeTranscript('pane-conversation', 'my own answer', NOW - 60_000);
|
||||
writeTranscript('unrelated-conversation', 'a stranger answer', NOW);
|
||||
writeHistory([{ sessionId: 'unrelated-conversation', timestamp: NOW }]);
|
||||
|
||||
expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) });
|
||||
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('credits a shared-cwd entry to the pane whose Enter is closest to it', async () => {
|
||||
const near = addPane('pane-near', 'near-conversation', NOW);
|
||||
const far = addPane('pane-far', 'far-conversation', NOW - 4_000);
|
||||
writeTranscript('near-conversation', 'near answer', NOW - 60_000);
|
||||
writeTranscript('far-conversation', 'far answer', NOW - 60_000);
|
||||
writeTranscript('fresh-conversation', 'the freshly cleared answer', NOW + 500);
|
||||
writeHistory([{ sessionId: 'fresh-conversation', timestamp: NOW + 100 }]);
|
||||
|
||||
// Both panes are inside the match window; only the closest may claim it.
|
||||
expect(await getLastResponse('pane-far')).toEqual({ text: 'far answer', timestamp: expect.any(String) });
|
||||
expect(far.adoptClaudeSessionId).not.toHaveBeenCalled();
|
||||
expect(await getLastResponse('pane-near')).toEqual({
|
||||
text: 'the freshly cleared answer',
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||||
let codexHome: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt)
|
||||
let session: any; // MockSession, loosened for codex-only fields (codexConfig, lastSubmitAt)
|
||||
let workdir: string;
|
||||
|
||||
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
|
||||
@@ -230,7 +230,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||||
|
||||
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
|
||||
const submitAtSec = BASE_MTIME + 500;
|
||||
session.codexLastSubmitAt = submitAtSec * 1000;
|
||||
session.lastSubmitAt = submitAtSec * 1000;
|
||||
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
|
||||
|
||||
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
|
||||
|
||||
@@ -18,7 +18,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
@@ -1288,6 +1288,315 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.sessions.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('decodes a dotdir working directory (e.g. ~/.codeman) instead of falling back to $HOME', async () => {
|
||||
// Claude Code's project-key encoding maps both '/' and '.' to '-', so
|
||||
// "/home/x/.dotcase" and "/home/x/dotcase" collapse to the same-looking
|
||||
// dash run except for a doubled dash. decodeProjectKey() must still
|
||||
// recover the real (dotdir) path rather than silently falling back to
|
||||
// bare $HOME (COD bug: 2026-08-01, ~/.codeman resumed sessions got
|
||||
// workingDir "/home/timkjr" instead of "/home/timkjr/.codeman").
|
||||
const home = process.env.HOME as string;
|
||||
const realDir = join(home, '.dotcase');
|
||||
await mkdir(realDir, { recursive: true });
|
||||
|
||||
const projectKey = realDir.replace(/\//g, '-').replace(/\./g, '-');
|
||||
const projDir = join(home, '.claude', 'projects', projectKey);
|
||||
await mkdir(projDir, { recursive: true });
|
||||
|
||||
const sessionId = '12345678-1234-1234-1234-123456789012';
|
||||
const transcriptLine = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n';
|
||||
// scanProjectDir skips files under 4000 bytes.
|
||||
const padding = '#'.repeat(4200 - transcriptLine.length);
|
||||
await writeFile(join(projDir, `${sessionId}.jsonl`), transcriptLine + padding);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/history/sessions?projectKey=${projectKey}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
const row = body.data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.workingDir).toBe(realDir);
|
||||
expect(row.workingDir).not.toBe(home);
|
||||
});
|
||||
|
||||
it('prefers the dotdir over a same-named non-dot sibling, and never emits a "//" path', async () => {
|
||||
// A doubled dash also lets the decoder read the empty split segment as a
|
||||
// directory NAME. `isDir(current + '/' + '')` stats `current + '/'`, which
|
||||
// always succeeds, so `~/.sib` + `~/sib` both existing used to resolve to
|
||||
// "/home/x//sib": the wrong directory, spelled with a double slash that
|
||||
// then fails every string comparison against session.workingDir. The empty
|
||||
// candidate is never a real path component, so it is skipped outright,
|
||||
// which is also what lets the dotdir branch below it run at all.
|
||||
const home = process.env.HOME as string;
|
||||
const dotDir = join(home, '.sib');
|
||||
await mkdir(dotDir, { recursive: true });
|
||||
await mkdir(join(home, 'sib'), { recursive: true });
|
||||
|
||||
const projectKey = dotDir.replace(/\//g, '-').replace(/\./g, '-');
|
||||
const projDir = join(home, '.claude', 'projects', projectKey);
|
||||
await mkdir(projDir, { recursive: true });
|
||||
|
||||
const sessionId = '22222222-2222-2222-2222-222222222222';
|
||||
const line = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n';
|
||||
await writeFile(join(projDir, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/history/sessions?projectKey=${projectKey}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.workingDir).toBe(dotDir);
|
||||
expect(row.workingDir).not.toContain('//');
|
||||
});
|
||||
|
||||
it('excludes non-interactive (SDK-driven) transcripts from the history list', async () => {
|
||||
// CI review bots and other automated tools write transcripts into the same
|
||||
// ~/.claude/projects tree as interactive sessions (entrypoint "sdk-py" etc.)
|
||||
// but were never something a user can resume into — no PTY, no running
|
||||
// process. They cluttered Past Sessions as blank rows or identical
|
||||
// boilerplate ("Review this change for security vulnerabilities...").
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const cliId = '33333333-3333-3333-3333-333333333333';
|
||||
const sdkId = '44444444-4444-4444-4444-444444444444';
|
||||
const noEntrypointId = '55555555-5555-5555-5555-555555555555';
|
||||
|
||||
const cliLine =
|
||||
JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real question' } }) +
|
||||
'\n';
|
||||
const sdkLine =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'sdk-py',
|
||||
message: { role: 'user', content: 'Review this change for security vulnerabilities.' },
|
||||
}) + '\n';
|
||||
// Older transcripts predate the entrypoint field entirely — must still show.
|
||||
const noEntrypointLine =
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'a pre-entrypoint session' } }) + '\n';
|
||||
|
||||
await writeFile(join(projPath, `${cliId}.jsonl`), cliLine + '#'.repeat(4200 - cliLine.length));
|
||||
await writeFile(join(projPath, `${sdkId}.jsonl`), sdkLine + '#'.repeat(4200 - sdkLine.length));
|
||||
await writeFile(
|
||||
join(projPath, `${noEntrypointId}.jsonl`),
|
||||
noEntrypointLine + '#'.repeat(4200 - noEntrypointLine.length)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-entrypoint-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
|
||||
expect(ids).toContain(cliId);
|
||||
expect(ids).toContain(noEntrypointId);
|
||||
expect(ids).not.toContain(sdkId);
|
||||
});
|
||||
|
||||
it('ignores a bookkeeping line that happens to mention "entrypoint" outside a real message record', async () => {
|
||||
// Scanning must anchor on "type":"user"/"assistant" lines specifically,
|
||||
// not any line that happens to contain the substring "entrypoint".
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-bookkeeping-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const sessionId = '77777777-7777-7777-7777-777777777777';
|
||||
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n';
|
||||
const realLine =
|
||||
JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real message' } }) +
|
||||
'\n';
|
||||
|
||||
// scanProjectDir skips files under 4000 bytes.
|
||||
const body = bookkeepingLine + realLine;
|
||||
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-entrypoint-bookkeeping-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
|
||||
expect(ids).toContain(sessionId);
|
||||
});
|
||||
|
||||
it('shows a session with ANY interactive (cli) message, even if an earlier message was automated', async () => {
|
||||
// "First field wins" would have misattributed this: an old transcript
|
||||
// whose true first message predates the entrypoint field, later resumed
|
||||
// under something automated (entrypoint: 'sdk-py' on message 2), then
|
||||
// continued interactively by a real person (entrypoint: 'cli' on message
|
||||
// 3). Stopping at the first entrypoint-bearing line found ('sdk-py')
|
||||
// would wrongly exclude a session a human genuinely used. One real
|
||||
// interactive message anywhere is enough to keep it visible.
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-any-cli-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const sessionId = '99999999-9999-9999-9999-999999999999';
|
||||
const firstLine =
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-entrypoint-field message' } }) + '\n';
|
||||
const automatedLine =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'sdk-py',
|
||||
message: { role: 'user', content: 'an automated follow-up' },
|
||||
}) + '\n';
|
||||
const interactiveLine =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'cli',
|
||||
message: { role: 'user', content: 'a real person continued this' },
|
||||
}) + '\n';
|
||||
|
||||
const body = firstLine + automatedLine + interactiveLine;
|
||||
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-entrypoint-any-cli-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
|
||||
expect(ids).toContain(sessionId);
|
||||
});
|
||||
|
||||
it('still excludes a session where every entrypoint-bearing message is automated', async () => {
|
||||
// Mirror of the previous test with no 'cli' message anywhere — proves the
|
||||
// "any cli wins" fix isn't just failing open unconditionally.
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-all-automated-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const firstLine =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'sdk-py',
|
||||
message: { role: 'user', content: 'Review this change for security vulnerabilities.' },
|
||||
}) + '\n';
|
||||
const secondLine =
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
entrypoint: 'sdk-py',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Looking at the diff...' }] },
|
||||
}) + '\n';
|
||||
|
||||
const body = firstLine + secondLine;
|
||||
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-entrypoint-all-automated-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
|
||||
expect(ids).not.toContain(sessionId);
|
||||
});
|
||||
|
||||
it('keeps a session whose entrypoint is an unrecognized non-SDK value (fail open)', async () => {
|
||||
// The exclusion is a blocklist on the SDK shape, NOT an allowlist on 'cli'.
|
||||
// An allowlist fails CLOSED on any value Claude Code has not shipped yet:
|
||||
// the day it stamps a new interactive entrypoint, nothing matches 'cli' and
|
||||
// the entire Past Sessions list silently goes blank. Excluding only what we
|
||||
// positively recognize as automated fails open instead — a few noisy rows,
|
||||
// not a dead feature.
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-unknown-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const sessionId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const line =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'cli-next',
|
||||
message: { role: 'user', content: 'a question from a future interactive host' },
|
||||
}) + '\n';
|
||||
await writeFile(join(projPath, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-entrypoint-unknown-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
|
||||
expect(ids).toContain(sessionId);
|
||||
});
|
||||
|
||||
it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => {
|
||||
// A session restarted many times over a long conversation accumulates a batch
|
||||
// of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation)
|
||||
// per restart, ahead of the real first message. With enough restarts these can
|
||||
// push the genuine first prompt past a 16KB head-read window even though the
|
||||
// message itself is tiny — the row showed up blank despite having real content.
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-bookkeeping-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const sessionId = '66666666-6666-6666-6666-666666666666';
|
||||
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n';
|
||||
// > 16KB (the old head-read size) but well under 128KB (the new one).
|
||||
const prefix = bookkeepingLine.repeat(Math.ceil(20000 / bookkeepingLine.length));
|
||||
const realLine =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'cli',
|
||||
message: { role: 'user', content: 'the real first message' },
|
||||
}) + '\n';
|
||||
expect(prefix.length).toBeGreaterThan(16384);
|
||||
|
||||
await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-bookkeeping-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.firstPrompt).toBe('the real first message');
|
||||
});
|
||||
|
||||
it('still falls back to the tail read when bookkeeping alone exceeds the new 128KB head window', async () => {
|
||||
// Raising the head buffer to 128KB helps most restart-heavy sessions, but an
|
||||
// even more extreme case (many more restarts) can still exceed it. This
|
||||
// proves the tail-read fallback itself is intact after the threshold
|
||||
// rewrite (`fileStat.size > headBuf.length` replacing the old hardcoded
|
||||
// 16384/65536) — the fallback's own logic, not the exact threshold value,
|
||||
// is what could have silently broken (e.g. a copy-paste slip that dropped
|
||||
// the `> headBuf.length` check entirely). The real message sits near the
|
||||
// end of the file, well inside the 32KB tail window, so a working fallback
|
||||
// finds it; a broken one leaves the row blank exactly like the bug this
|
||||
// whole fix addresses.
|
||||
const home = process.env.HOME as string;
|
||||
const projPath = join(home, '.claude', 'projects', 'proj-tail-fallback-test');
|
||||
await mkdir(projPath, { recursive: true });
|
||||
|
||||
const sessionId = '88888888-8888-8888-8888-888888888888';
|
||||
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n';
|
||||
// Comfortably past the new 128KB head window (was 16KB), so the head read
|
||||
// never reaches a single "type":"user"/"assistant"/"summary" line.
|
||||
const prefix = bookkeepingLine.repeat(Math.ceil(140000 / bookkeepingLine.length));
|
||||
const realLine =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
entrypoint: 'cli',
|
||||
message: { role: 'user', content: 'found via tail fallback' },
|
||||
}) + '\n';
|
||||
expect(prefix.length).toBeGreaterThan(131072);
|
||||
|
||||
await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions?projectKey=proj-tail-fallback-test',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.firstPrompt).toBe('found via tail fallback');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
@@ -81,12 +81,18 @@ vi.mock('../../src/utils/gemini-cli-resolver.js', () => ({
|
||||
resolveGeminiDir: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/antigravity-cli-resolver.js', () => ({
|
||||
isAntigravityAvailable: vi.fn(() => false),
|
||||
resolveAntigravityDir: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
||||
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
||||
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
|
||||
import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
|
||||
import { isAntigravityAvailable, resolveAntigravityDir } from '../../src/utils/antigravity-cli-resolver.js';
|
||||
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
@@ -98,6 +104,8 @@ const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
||||
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
||||
const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
|
||||
const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
|
||||
const mockedIsAntigravityAvailable = vi.mocked(isAntigravityAvailable);
|
||||
const mockedResolveAntigravityDir = vi.mocked(resolveAntigravityDir);
|
||||
|
||||
describe('system-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
@@ -805,6 +813,32 @@ describe('system-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/antigravity/status ==========
|
||||
|
||||
describe('GET /api/antigravity/status', () => {
|
||||
it('returns unavailable when agy is not installed', async () => {
|
||||
mockedIsAntigravityAvailable.mockReturnValue(false);
|
||||
mockedResolveAntigravityDir.mockReturnValue(null);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/antigravity/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.available).toBe(false);
|
||||
expect(body.path).toBeNull();
|
||||
});
|
||||
|
||||
it('returns available with path when agy is installed', async () => {
|
||||
mockedIsAntigravityAvailable.mockReturnValue(true);
|
||||
mockedResolveAntigravityDir.mockReturnValue('/home/user/.local/bin');
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/antigravity/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.available).toBe(true);
|
||||
expect(body.path).toBe('/home/user/.local/bin');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/execution/model-config ==========
|
||||
|
||||
describe('GET /api/execution/model-config', () => {
|
||||
|
||||
+315
-1
@@ -71,9 +71,114 @@ describe('run mode UI', () => {
|
||||
expect(app.runMode).toBe('gemini');
|
||||
expect(runBtnLabel.textContent).toBe('Run GM');
|
||||
});
|
||||
|
||||
it('accepts Antigravity mode from server sync and updates the run button label', async () => {
|
||||
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
||||
|
||||
storage.set('codeman_runMode', 'claude');
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'antigravity' }));
|
||||
|
||||
expect(app.runMode).toBe('antigravity');
|
||||
expect(runBtnLabel.textContent).toBe('Run AG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Run launch synchronization', () => {
|
||||
it('keeps launch progress out of an active session terminal', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.activeSessionId = 'existing-session';
|
||||
app.terminal = {
|
||||
clear: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
};
|
||||
app.showToast = vi.fn();
|
||||
|
||||
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
||||
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
||||
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
||||
|
||||
expect(ownsTerminal).toBe(false);
|
||||
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||
expect(app.terminal.writeln).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info');
|
||||
expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error');
|
||||
});
|
||||
|
||||
it('still renders launch progress in the terminal on the session-less home screen', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.activeSessionId = null; // home screen: nothing else owns the terminal
|
||||
app.terminal = { clear: vi.fn(), writeln: vi.fn() };
|
||||
app.showToast = vi.fn();
|
||||
|
||||
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
||||
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
||||
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
||||
|
||||
expect(ownsTerminal).toBe(true);
|
||||
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session');
|
||||
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session');
|
||||
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed');
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Static guard over session-ui.js itself. The helpers above can be perfectly
|
||||
* correct while a run*() entry point still writes to the shared xterm
|
||||
* directly, which is the actual bug: a launch started while another session
|
||||
* is active wipes that session's terminal, and _cleanupPreviousSession()
|
||||
* then serializes the wiped view into its restore snapshot. Asserting on the
|
||||
* helpers alone cannot see that, so pin the call sites here. This also
|
||||
* covers run modes added later, which is how runAntigravity was caught.
|
||||
*/
|
||||
it('routes every run mode through the ownership helpers, never the terminal directly', () => {
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
|
||||
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
||||
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
||||
const bodies = new Map<string, string>();
|
||||
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
|
||||
for (let m = header.exec(src); m; m = header.exec(src)) {
|
||||
const start = m.index + m[0].length;
|
||||
const end = src.indexOf('\n },', start);
|
||||
expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start);
|
||||
bodies.set(m[1], src.slice(start, end));
|
||||
}
|
||||
|
||||
// Fail loudly if the scan matched nothing: a silently empty scan would make
|
||||
// every assertion below vacuously true.
|
||||
expect([...bodies.keys()]).toEqual(
|
||||
expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity'])
|
||||
);
|
||||
|
||||
for (const [name, body] of bodies) {
|
||||
expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear(');
|
||||
expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain(
|
||||
'this.terminal.writeln('
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
||||
const runBtn = {
|
||||
disabled: false,
|
||||
@@ -178,14 +283,169 @@ describe('Codex quick start settings', () => {
|
||||
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
|
||||
);
|
||||
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(claudeTab?.[1]).not.toContain('appSettingsCodexAnimations');
|
||||
|
||||
const codexTab = html.match(
|
||||
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
|
||||
);
|
||||
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(codexTab?.[1]).toContain('appSettingsCodexAnimations');
|
||||
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
|
||||
});
|
||||
|
||||
describe('Codex CLI tab visibility', () => {
|
||||
// Both settings on the tab are handed to `codex` at launch, so on an instance
|
||||
// where the binary does not resolve the tab is a promise nothing can keep.
|
||||
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
|
||||
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
|
||||
// so deleting the call in openAppSettings() is what it is meant to catch.
|
||||
function loadSettingsUi(codexAvailable: boolean | undefined) {
|
||||
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context: any = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: {
|
||||
getElementById: () => null,
|
||||
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
|
||||
},
|
||||
console,
|
||||
});
|
||||
context.window = context;
|
||||
if (codexAvailable !== undefined) context.__codemanCliAvailable = { codex: codexAvailable };
|
||||
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
||||
return { app: new (CodemanApp as any)(), codexTabBtn };
|
||||
}
|
||||
|
||||
it('hides the Codex tab when the codex binary is not available', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(false);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('hides the Codex tab when the availability flag was never injected', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(undefined);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('shows the Codex tab when codex is available', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(true);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('applies the gating from openAppSettings, not just in isolation', () => {
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
const open = src.slice(src.indexOf('\n openAppSettings() {'));
|
||||
const body = open.slice(0, open.indexOf('\n },'));
|
||||
expect(body).toContain('_applyCodexSettingsVisibility()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI availability gating (#200/#201)', () => {
|
||||
// Drives the REAL settings-ui.js + session-ui.js against stub elements, so an
|
||||
// added run mode that nobody wires up here is what these are meant to catch.
|
||||
function loadUi(flags: Record<string, boolean> | undefined) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const welcomeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const id of ['welcomeClaudeBtn', 'welcomeOpencodeBtn', 'welcomeGeminiBtn', 'welcomeTunnelBtn']) {
|
||||
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const modeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']) {
|
||||
modeBtns[mode] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const menu = {
|
||||
querySelector: (sel: string) => {
|
||||
const m = sel.match(/data-mode="([^"]+)"/);
|
||||
return m ? (modeBtns[m[1]] ?? null) : null;
|
||||
},
|
||||
};
|
||||
const context: any = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => welcomeBtns[id] ?? null, querySelector: () => null },
|
||||
console,
|
||||
});
|
||||
context.window = context;
|
||||
if (flags !== undefined) context.__codemanCliAvailable = flags;
|
||||
for (const file of ['settings-ui.js', 'session-ui.js']) {
|
||||
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
||||
vm.runInContext(src, context, { filename: file });
|
||||
}
|
||||
return { app: new (CodemanApp as any)(), welcomeBtns, modeBtns, menu };
|
||||
}
|
||||
|
||||
const ALL_OFF = {
|
||||
claude: false,
|
||||
opencode: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
cloudflared: false,
|
||||
};
|
||||
|
||||
it('hides each welcome button whose tool is missing, including the tunnel', () => {
|
||||
const { app, welcomeBtns } = loadUi({ ...ALL_OFF, claude: true });
|
||||
app.applyWelcomeCliVisibility();
|
||||
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
|
||||
expect(welcomeBtns.welcomeOpencodeBtn.style.display).toBe('none');
|
||||
expect(welcomeBtns.welcomeGeminiBtn.style.display).toBe('none');
|
||||
// #200 originally DELETED the tunnel button and its QR outright; it is gated
|
||||
// on cloudflared instead, so a box that has cloudflared keeps the feature.
|
||||
expect(welcomeBtns.welcomeTunnelBtn.style.display).toBe('none');
|
||||
|
||||
const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
|
||||
withTunnel.app.applyWelcomeCliVisibility();
|
||||
expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
|
||||
});
|
||||
|
||||
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
|
||||
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(modeBtns.claude.style.display).toBe('flex');
|
||||
expect(modeBtns.antigravity.style.display).toBe('flex');
|
||||
expect(modeBtns.opencode.style.display).toBe('none');
|
||||
expect(modeBtns.codex.style.display).toBe('none');
|
||||
expect(modeBtns.gemini.style.display).toBe('none');
|
||||
// Shell needs no external CLI, and leaving it alone is what guarantees the
|
||||
// menu is never empty on a box with nothing installed.
|
||||
expect(modeBtns.shell.style.display).toBe('PRISTINE');
|
||||
});
|
||||
|
||||
it('gates every mode the run-mode menu actually offers', () => {
|
||||
// Catches a sixth run mode being added to index.html without being gated,
|
||||
// which is exactly how antigravity slipped past #201.
|
||||
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||
const menuHtml = html.slice(html.indexOf('id="runModeMenu"'));
|
||||
const offered = [...menuHtml.slice(0, menuHtml.indexOf('</div>')).matchAll(/data-mode="([^"]+)"/g)].map(
|
||||
(m) => m[1]
|
||||
);
|
||||
expect(offered).toContain('antigravity');
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
|
||||
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
|
||||
const gated = fn.slice(0, fn.indexOf('\n },'));
|
||||
for (const mode of offered.filter((m) => m !== 'shell')) {
|
||||
expect(gated).toContain(`'${mode}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows everything when the flags were never injected', () => {
|
||||
// A cached page from a build without the injection, or a solo popup. Hiding
|
||||
// every run button on a doubt would leave a working install nothing to click.
|
||||
const { app, welcomeBtns, modeBtns, menu } = loadUi(undefined);
|
||||
app.applyWelcomeCliVisibility();
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
|
||||
expect(modeBtns.gemini.style.display).toBe('flex');
|
||||
});
|
||||
});
|
||||
|
||||
it('passes global Codex settings into quick-start config for new sessions', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'codex-case' },
|
||||
@@ -222,6 +482,7 @@ describe('Codex quick start settings', () => {
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||
app.loadAppSettingsFromStorage = () => ({
|
||||
codexDangerouslyBypassApprovals: true,
|
||||
codexAnimationsEnabled: false,
|
||||
});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
@@ -240,7 +501,7 @@ describe('Codex quick start settings', () => {
|
||||
mode: 'codex',
|
||||
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
|
||||
sessionName: 'w1-codex-case',
|
||||
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
|
||||
codexConfig: { dangerouslyBypassApprovals: true, animations: false, renderMode: 'hybrid' },
|
||||
});
|
||||
expect(selected).toEqual(['sess-1']);
|
||||
});
|
||||
@@ -557,3 +818,56 @@ describe('Gemini quick start', () => {
|
||||
expect(selected).toEqual(['sess-gm']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Antigravity quick start', () => {
|
||||
// Same envelope-unwrap regression guard as the Gemini block above, for runAntigravity().
|
||||
it('drives runAntigravity() through the {success,data} envelope and selects the new session', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'ag-case' },
|
||||
};
|
||||
const requests: Array<{ url: string; body?: any }> = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||
fetch: async (url: string, init?: { body?: string }) => {
|
||||
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
||||
if (url === '/api/antigravity/status')
|
||||
return { json: async () => ({ success: true, data: { available: true } }) };
|
||||
if (url === '/api/quick-start')
|
||||
return { json: async () => ({ success: true, data: { sessionId: 'sess-ag' } }) };
|
||||
if (url === '/api/sessions/sess-ag')
|
||||
return { json: async () => ({ success: true, data: { id: 'sess-ag', name: 'w1-ag-case' } }) };
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
});
|
||||
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
app.sessions = new Map();
|
||||
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
||||
app._renderSessionTabsImmediate = vi.fn();
|
||||
const selected: string[] = [];
|
||||
app.selectSession = async (id: string) => {
|
||||
selected.push(id);
|
||||
};
|
||||
|
||||
await app.runAntigravity();
|
||||
|
||||
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||
caseName: 'ag-case',
|
||||
mode: 'antigravity',
|
||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
||||
});
|
||||
expect(selected).toEqual(['sess-ag']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,8 +96,16 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
|
||||
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
||||
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
||||
// .js/.css refs; strip them so the title remains the only other change.
|
||||
const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
|
||||
// .js/.css refs, and injects the CLI-availability flags before </head>; strip
|
||||
// both so the title remains the only other change.
|
||||
//
|
||||
// The flag strip is what keeps this test environment-independent. It used to
|
||||
// pass here by luck: the availability script was injected only where a CLI
|
||||
// resolved, so the assertion held on a machine with none installed and would
|
||||
// have failed on a developer's box that had them.
|
||||
const html = (await render('laptop'))
|
||||
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
|
||||
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '');
|
||||
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
||||
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
||||
expect(html.startsWith(beforeTitle)).toBe(true);
|
||||
|
||||
@@ -270,6 +270,75 @@ describe('mergeUnifiedSessions', () => {
|
||||
expect(live!.firstPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => {
|
||||
// A pure history row already got its own real scan (step 1 keys it under its
|
||||
// OWN sessionId) — if that extraction genuinely failed (oversized first
|
||||
// message, noise-filtered, etc.), the workingDir guess must not paper over
|
||||
// it with an unrelated session's opening line. Regression: an old session
|
||||
// in a shared workingDir was displaying TODAY's live session's firstPrompt
|
||||
// as its own, because the guess didn't check whether this row already had
|
||||
// its own (failed) attempt.
|
||||
const merged = mergeUnifiedSessions({
|
||||
history: [
|
||||
// This session's own transcript scan found no usable prompt.
|
||||
{
|
||||
sessionId: 'old-uuid',
|
||||
workingDir: '/shared',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: undefined,
|
||||
},
|
||||
// A much newer, unrelated session in the same directory.
|
||||
{
|
||||
sessionId: 'newer-uuid',
|
||||
workingDir: '/shared',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-06-01T00:00:00.000Z',
|
||||
firstPrompt: "today's real prompt",
|
||||
},
|
||||
],
|
||||
});
|
||||
const old = merged.find((m) => m.sessionId === 'old-uuid');
|
||||
expect(old).toBeDefined();
|
||||
expect(old!.firstPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a RESUMED session blank rather than borrowing a sibling, once its own transcript is aliased in', () => {
|
||||
// The exact scenario COD-140's own comment lists first: a live/persisted row
|
||||
// whose claudeSessionId aliases to an on-disk transcript. Once that alias
|
||||
// successfully folds the transcript's own (failed) extraction into this row
|
||||
// (sources includes 'history'), it must NOT then fall through to the
|
||||
// workingDir guess and borrow an unrelated sibling's prompt -- same bug as
|
||||
// the plain history-only case above, but for the resumed-session path the
|
||||
// backfill mechanism was actually built for.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-resumed', status: 'working', claudeSessionId: 'resumed-uuid', workingDir: '/shared' }],
|
||||
history: [
|
||||
// The resumed session's OWN transcript -- aliased in via claudeSessionId,
|
||||
// but its own extraction found nothing.
|
||||
{
|
||||
sessionId: 'resumed-uuid',
|
||||
workingDir: '/shared',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: undefined,
|
||||
},
|
||||
// An unrelated, newer sibling in the same directory.
|
||||
{
|
||||
sessionId: 'sibling-uuid',
|
||||
workingDir: '/shared',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-06-01T00:00:00.000Z',
|
||||
firstPrompt: "unrelated sibling's prompt",
|
||||
},
|
||||
],
|
||||
});
|
||||
const resumed = merged.find((m) => m.sessionId === 'codeman-resumed');
|
||||
expect(resumed).toBeDefined();
|
||||
expect([...resumed!.sources].sort()).toEqual(['history', 'live']);
|
||||
expect(resumed!.firstPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
|
||||
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
|
||||
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @fileoverview The pane's last-Enter timestamp must survive a Codeman restart.
|
||||
*
|
||||
* `start()` resets `claudeSessionId` to the launch id even when re-attaching to
|
||||
* a mux session whose CLI has since moved on (a `/clear` before the restart), so
|
||||
* `lastSubmitAt` is the response viewer's only anchor for re-deriving the live
|
||||
* conversation. If it is not persisted, a recovered pane shows the pre-`/clear`
|
||||
* transcript until the user happens to type again — hours, in practice.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
describe('session submit anchor', () => {
|
||||
it('records the pane Enter and carries it into persisted state', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
expect(session.lastSubmitAt).toBe(0);
|
||||
expect(session.toState().lastSubmitAt).toBeUndefined();
|
||||
|
||||
const before = Date.now();
|
||||
session.write('hello\r');
|
||||
const after = Date.now();
|
||||
|
||||
expect(session.lastSubmitAt).toBeGreaterThanOrEqual(before);
|
||||
expect(session.lastSubmitAt).toBeLessThanOrEqual(after);
|
||||
expect(session.toState().lastSubmitAt).toBe(session.lastSubmitAt);
|
||||
});
|
||||
|
||||
it('leaves the anchor unset for keystrokes that never submit', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
session.write('hello');
|
||||
session.write('\x1b[A'); // arrow-up: history recall, not a submit
|
||||
|
||||
expect(session.lastSubmitAt).toBe(0);
|
||||
expect(session.toState().lastSubmitAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores the anchor from persisted state on boot recovery', () => {
|
||||
const submitted = new Session({ workingDir: '/tmp' });
|
||||
submitted.write('prompt\r');
|
||||
const persisted = submitted.toState();
|
||||
|
||||
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: persisted.lastSubmitAt });
|
||||
|
||||
expect(recovered.lastSubmitAt).toBe(submitted.lastSubmitAt);
|
||||
expect(recovered.toState().lastSubmitAt).toBe(submitted.lastSubmitAt);
|
||||
});
|
||||
|
||||
it('starts a pane with no persisted anchor at zero rather than NaN', () => {
|
||||
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: undefined });
|
||||
expect(recovered.lastSubmitAt).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Regression tests for issue #208 — "Plain shell PTY exits with code 1 after
|
||||
* successful tmux creation in Docker".
|
||||
*
|
||||
* The shell-mode pane command used to be the literal string `$SHELL`. It ends up
|
||||
* inside the `bash -c "…"` argument of the respawn-pane line, which execSync runs
|
||||
* through `/bin/sh -c`, so it was expanded by the SERVER process's shell against
|
||||
* the SERVER process's env. Containers (and system-level systemd units) do not set
|
||||
* SHELL, so it expanded to nothing and the pane command ended in a dangling `&&`:
|
||||
*
|
||||
* bash: -c: line 1: syntax error: unexpected end of file
|
||||
*
|
||||
* These tests pin the resolver's guarantees and assert that a shell launch command
|
||||
* survives the outer `sh -c` layer with an unset SHELL.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { loginShellArgs, resolveLocalShell } from '../src/utils/shell-resolver.js';
|
||||
|
||||
describe('resolveLocalShell', () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = originalShell;
|
||||
});
|
||||
|
||||
it('returns an absolute executable path when SHELL is unset (container case)', () => {
|
||||
delete process.env.SHELL;
|
||||
const shell = resolveLocalShell();
|
||||
expect(shell).not.toBe('');
|
||||
expect(shell.startsWith('/')).toBe(true);
|
||||
// Proves the resolved path is really launchable, not just a plausible string.
|
||||
expect(execFileSync(shell, ['-c', 'echo ok'], { encoding: 'utf8' }).trim()).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns an absolute executable path when SHELL is empty or whitespace', () => {
|
||||
for (const value of ['', ' ']) {
|
||||
process.env.SHELL = value;
|
||||
const shell = resolveLocalShell();
|
||||
expect(shell.startsWith('/')).toBe(true);
|
||||
expect(execFileSync(shell, ['-c', 'echo ok'], { encoding: 'utf8' }).trim()).toBe('ok');
|
||||
}
|
||||
});
|
||||
|
||||
it('honors a valid $SHELL', () => {
|
||||
process.env.SHELL = '/bin/sh';
|
||||
expect(resolveLocalShell()).toBe('/bin/sh');
|
||||
});
|
||||
|
||||
it('ignores a $SHELL that does not exist', () => {
|
||||
process.env.SHELL = '/nonexistent/shell-that-is-not-here';
|
||||
const shell = resolveLocalShell();
|
||||
expect(shell).not.toBe('/nonexistent/shell-that-is-not-here');
|
||||
expect(shell.startsWith('/')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a relative $SHELL (never emits a bare word into the launch command)', () => {
|
||||
process.env.SHELL = 'bash';
|
||||
expect(resolveLocalShell().startsWith('/')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores nologin-style stubs that would exit instantly', () => {
|
||||
process.env.SHELL = '/usr/sbin/nologin';
|
||||
expect(resolveLocalShell()).not.toContain('nologin');
|
||||
process.env.SHELL = '/bin/false';
|
||||
expect(resolveLocalShell()).not.toBe('/bin/false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginShellArgs (#209 login flags, allowlisted)', () => {
|
||||
it('asks for a login shell on the POSIX-family shells that accept the flags', () => {
|
||||
for (const shell of ['/bin/sh', '/bin/bash', '/bin/dash', '/usr/bin/zsh', '/usr/local/bin/fish', '/bin/ksh']) {
|
||||
expect(loginShellArgs(shell)).toBe(' -i -l');
|
||||
}
|
||||
});
|
||||
|
||||
it('adds nothing for shells that take neither flag, so the pane cannot die on arrival', () => {
|
||||
// The shell path can come from the passwd entry, which is user data and can
|
||||
// name anything. A shell that rejects an unknown flag exits immediately —
|
||||
// indistinguishable from the #208 dead-pane-on-arrival this module prevents.
|
||||
// csh/tcsh are here too: tcsh honors -l only when it is the ONLY flag.
|
||||
for (const shell of ['/usr/bin/nu', '/usr/bin/elvish', '/usr/bin/xonsh', '/bin/tcsh', '/bin/csh']) {
|
||||
expect(loginShellArgs(shell)).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('really launches for every allowlisted shell present on this machine', () => {
|
||||
// The whole point of the allowlist is that the flags are ACCEPTED, so prove it
|
||||
// against the real binaries rather than trusting the set.
|
||||
for (const shell of ['/bin/sh', '/bin/bash', '/bin/dash', '/usr/bin/zsh', '/bin/ksh']) {
|
||||
let exists = true;
|
||||
try {
|
||||
execFileSync('/bin/sh', ['-c', `test -x ${shell}`]);
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
if (!exists) continue;
|
||||
const out = execFileSync('/bin/sh', ['-c', `${shell} -i -l -c 'echo ok' 2>/dev/null`], { encoding: 'utf8' });
|
||||
expect(out).toContain('ok');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell-mode spawn command (issue #208)', () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.SHELL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = originalShell;
|
||||
});
|
||||
|
||||
it('never emits an unexpanded $SHELL into the pane command', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||
expect(cmd).not.toContain('$SHELL');
|
||||
expect(cmd.trim()).not.toBe('');
|
||||
});
|
||||
|
||||
it('launches a LOGIN shell, matching what tmux does for a pane with no default-command', () => {
|
||||
// A tmux pane already hands the shell a tty, so it is interactive either way
|
||||
// (`$-` contains `i` for a bare /bin/bash in a pane, which is why ~/.bashrc has
|
||||
// always been sourced). `-l` is the flag that changes anything: it is what
|
||||
// picks up /etc/profile and /etc/profile.d/*, which a systemd --user service
|
||||
// never sourced, so its minimal PATH is what every pane used to inherit.
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||
expect(cmd.trim().endsWith('-i -l')).toBe(true);
|
||||
});
|
||||
|
||||
it('produces a launch command that parses after the outer sh -c expansion layer', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||
// Mirrors tmux-manager: `… bash -c ${JSON.stringify(launchCmd)}` handed to `sh -c`.
|
||||
const launchCmd = `cd ${JSON.stringify('/tmp')} && export CODEMAN_MUX=1 && ${cmd}`;
|
||||
const outer = `bash -n -c ${JSON.stringify(launchCmd)}`;
|
||||
|
||||
// `bash -n` parses without executing: exits 0 on the fix, 2 with the dangling `&&`.
|
||||
const result = execFileSync('/bin/sh', ['-c', `${outer}; echo "rc=$?"`], { encoding: 'utf8' });
|
||||
expect(result).toContain('rc=0');
|
||||
expect(result).not.toContain('unexpected end of file');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Smart-copy chord gate (#211).
|
||||
*
|
||||
* `Ctrl+C` has to keep meaning "interrupt" whenever nothing is selected, so the
|
||||
* decision is split in two: `shouldCopyTerminalSelectionFromShortcut()` only
|
||||
* answers "did this chord ask to copy", and the caller in the xterm custom key
|
||||
* handler decides what to do when there is no selection. These tests pin the
|
||||
* gate itself (registry-aware, keydown-only) plus the static invariants that
|
||||
* keep the generic capture loop from ever swallowing the interrupt.
|
||||
*
|
||||
* Strategy: run terminal-ui.js in a vm with a stub CodemanApp, the same harness
|
||||
* shape test/command-palette-ui.test.ts uses for panels-ui.js. No DOM, no xterm.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
type Shortcut = {
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
bindings?: Array<{ modifiers?: string[]; key?: string; code?: string }>;
|
||||
};
|
||||
|
||||
function loadTerminalHarness(registry?: Shortcut[]) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: () => null, querySelector: () => null },
|
||||
console,
|
||||
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
Object,
|
||||
});
|
||||
const terminalUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(terminalUi, context, { filename: 'terminal-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||
if (registry) {
|
||||
app.getShortcutRegistry = () => registry;
|
||||
// Real implementation, copied by reference from app.js semantics: ctrl/meta are
|
||||
// interchangeable, every other modifier must be declared by the binding.
|
||||
app.matchesShortcutEvent = (e: any, shortcut: Shortcut) => {
|
||||
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
|
||||
return shortcut.bindings.some((binding) => {
|
||||
const mods = binding.modifiers || [];
|
||||
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
|
||||
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
|
||||
if (mods.includes('shift') !== !!e.shiftKey) return false;
|
||||
if (mods.includes('alt') !== !!e.altKey) return false;
|
||||
if (binding.code && e.code === binding.code) return true;
|
||||
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
const DEFAULT_REGISTRY: Shortcut[] = [
|
||||
{
|
||||
id: 'copy-selection',
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: 'c' },
|
||||
{ modifiers: ['ctrl', 'shift'], key: 'C' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function keydown(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: 'keydown',
|
||||
key: 'c',
|
||||
code: 'KeyC',
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('terminal smart-copy gate', () => {
|
||||
it('matches the default Ctrl+C and Ctrl+Shift+C chords', () => {
|
||||
const app = loadTerminalHarness(DEFAULT_REGISTRY);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(true);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'C', shiftKey: true }))).toBe(true);
|
||||
// Cmd+C on macOS: the registry treats ctrl/meta as interchangeable.
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false, metaKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores plain typing and unrelated chords', () => {
|
||||
const app = loadTerminalHarness(DEFAULT_REGISTRY);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false }))).toBe(false);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'k', code: 'KeyK' }))).toBe(false);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'v', code: 'KeyV' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('only decides on keydown (the handler also runs for keypress and keyup)', () => {
|
||||
const app = loadTerminalHarness(DEFAULT_REGISTRY);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ type: 'keypress' }))).toBe(false);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ type: 'keyup' }))).toBe(false);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a disabled shortcut so Ctrl+C goes back to being the interrupt', () => {
|
||||
const app = loadTerminalHarness([{ ...DEFAULT_REGISTRY[0], disabled: true }]);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(false);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'C', shiftKey: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a rebound chord and stops claiming the old one', () => {
|
||||
const app = loadTerminalHarness([{ id: 'copy-selection', bindings: [{ modifiers: ['alt'], key: 'y' }] }]);
|
||||
expect(
|
||||
app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false, altKey: true, key: 'y', code: 'KeyY' }))
|
||||
).toBe(true);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the default chord when no registry is available', () => {
|
||||
const app = loadTerminalHarness(); // no getShortcutRegistry / matchesShortcutEvent
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(true);
|
||||
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'x', code: 'KeyX' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart-copy wiring invariants', () => {
|
||||
it('registers copy-selection in the shortcut registry', () => {
|
||||
expect(APP_SOURCE).toContain("id: 'copy-selection'");
|
||||
expect(APP_SOURCE).toContain("action: 'copyTerminalSelection'");
|
||||
});
|
||||
|
||||
it('keeps copyTerminalSelection OUT of SHORTCUT_ACTIONS', () => {
|
||||
// The generic capture loop preventDefaults on every match it dispatches. If
|
||||
// the copy action were reachable from there, Ctrl+C would be swallowed with
|
||||
// no selection and the user would lose the interrupt key.
|
||||
const actionsBlock = APP_SOURCE.slice(
|
||||
APP_SOURCE.indexOf('const SHORTCUT_ACTIONS = {'),
|
||||
APP_SOURCE.indexOf('// Use capture to handle before terminal')
|
||||
);
|
||||
expect(actionsBlock.length).toBeGreaterThan(0);
|
||||
expect(actionsBlock).not.toContain('copyTerminalSelection');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Smart copy in a real browser (#211).
|
||||
*
|
||||
* The gate itself is unit-tested in test/terminal-copy-selection.test.ts. What
|
||||
* can only be proven in a browser is the half that decides whether the PTY sees
|
||||
* an interrupt: xterm calls the custom key handler BEFORE its own cancel(), so
|
||||
* returning false does not preventDefault, and a synthetic KeyboardEvent never
|
||||
* triggers a browser default action. Both facts mean the copy/interrupt split
|
||||
* has to be driven with real key presses.
|
||||
*
|
||||
* Assertions are on real state: what landed on the clipboard, and what xterm
|
||||
* emitted through onData (the bytes that would reach the PTY).
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally: npm test -- test/terminal-copy-shortcut.test.ts
|
||||
*
|
||||
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
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 = 3174;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('terminal Ctrl+C smart copy', () => {
|
||||
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 });
|
||||
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
page = await context.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
// The first write after load can be dropped while the app finishes wiring
|
||||
// its render pipeline, so poll until one really lands in the buffer.
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const term = (window as any).app.terminal;
|
||||
await new Promise((r) => term.write('\r\nWARMUP\r\n', r));
|
||||
const buf = term.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('WARMUP')) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
null,
|
||||
{ timeout: 20000, polling: 500 }
|
||||
);
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/** Write a marker line, optionally select it, and reset the capture state. */
|
||||
async function setup(line: string, select: boolean, overrides: Record<string, unknown> = {}) {
|
||||
await page.evaluate(
|
||||
async ({ line, select, overrides }) => {
|
||||
const app = (window as any).app;
|
||||
const term = app.terminal;
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.shortcutOverrides = overrides;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
(window as any).__data = [];
|
||||
if (!(window as any).__dataHooked) {
|
||||
term.onData((d: string) => (window as any).__data.push(d));
|
||||
(window as any).__dataHooked = true;
|
||||
}
|
||||
await new Promise((r) => term.write('\r\n' + line + '\r\n', r));
|
||||
term.clearSelection();
|
||||
if (select) {
|
||||
const buf = term.buffer.active;
|
||||
let row = -1;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes(line)) row = i;
|
||||
}
|
||||
if (row === -1) throw new Error('marker line not found in buffer');
|
||||
term.select(0, row, line.length);
|
||||
if (!(term.getSelection() || '').trim()) throw new Error('selection is empty');
|
||||
}
|
||||
document.querySelector('.xterm-helper-textarea')!.dispatchEvent(new Event('focus'));
|
||||
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
|
||||
await navigator.clipboard.writeText('SENTINEL');
|
||||
},
|
||||
{ line, select, overrides }
|
||||
);
|
||||
}
|
||||
|
||||
async function outcome() {
|
||||
await page.waitForTimeout(350);
|
||||
return page.evaluate(async () => ({
|
||||
data: (window as any).__data as string[],
|
||||
clipboard: (await navigator.clipboard.readText()).trim(),
|
||||
hasSelection: (window as any).app.terminal.hasSelection(),
|
||||
}));
|
||||
}
|
||||
|
||||
it('copies the selection and sends nothing to the PTY', async () => {
|
||||
await setup('COPY-CASE-SELECTED', true);
|
||||
await page.keyboard.press('Control+c');
|
||||
const res = await outcome();
|
||||
expect(res.clipboard).toBe('COPY-CASE-SELECTED');
|
||||
expect(res.data).toEqual([]);
|
||||
expect(res.hasSelection).toBe(false); // cleared, so a second Ctrl+C interrupts
|
||||
});
|
||||
|
||||
it('still interrupts when nothing is selected', async () => {
|
||||
await setup('COPY-CASE-UNSELECTED', false);
|
||||
await page.keyboard.press('Control+c');
|
||||
const res = await outcome();
|
||||
expect(res.data).toEqual(['\x03']);
|
||||
expect(res.clipboard).toBe('SENTINEL');
|
||||
});
|
||||
|
||||
it('copies on the explicit Ctrl+Shift+C chord', async () => {
|
||||
await setup('COPY-CASE-EXPLICIT', true);
|
||||
await page.keyboard.press('Control+Shift+C');
|
||||
const res = await outcome();
|
||||
expect(res.clipboard).toBe('COPY-CASE-EXPLICIT');
|
||||
expect(res.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('never interrupts on Ctrl+Shift+C with an empty selection', async () => {
|
||||
await setup('COPY-CASE-EXPLICIT-EMPTY', false);
|
||||
await page.keyboard.press('Control+Shift+C');
|
||||
const res = await outcome();
|
||||
expect(res.data).toEqual([]);
|
||||
expect(res.clipboard).toBe('SENTINEL');
|
||||
});
|
||||
|
||||
it('restores the plain interrupt when the shortcut is disabled', async () => {
|
||||
await setup('COPY-CASE-DISABLED', true, { 'copy-selection': { disabled: true } });
|
||||
await page.keyboard.press('Control+c');
|
||||
const res = await outcome();
|
||||
expect(res.data).toEqual(['\x03']);
|
||||
expect(res.clipboard).toBe('SENTINEL');
|
||||
});
|
||||
|
||||
it('follows a rebind, and Ctrl+C goes back to pure interrupt', async () => {
|
||||
await setup('COPY-CASE-REBOUND', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
|
||||
await page.keyboard.press('Alt+y');
|
||||
const rebound = await outcome();
|
||||
expect(rebound.clipboard).toBe('COPY-CASE-REBOUND');
|
||||
expect(rebound.data).toEqual([]);
|
||||
|
||||
await setup('COPY-CASE-REBOUND-2', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
|
||||
await page.keyboard.press('Control+c');
|
||||
const res = await outcome();
|
||||
expect(res.data).toEqual(['\x03']);
|
||||
});
|
||||
|
||||
it('leaves Ctrl+V on the paste trap', async () => {
|
||||
await setup('COPY-CASE-PASTE', false);
|
||||
await page.keyboard.press('Control+v');
|
||||
const res = await outcome();
|
||||
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
|
||||
expect(res.data.join('')).not.toContain('\x16');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TmuxManager,
|
||||
buildCodexCommand,
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
@@ -98,6 +99,14 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex command builder', () => {
|
||||
it('controls decorative TUI animation through Codex config', () => {
|
||||
expect(buildCodexCommand({ animations: false })).toBe('codex --config tui.animations=false');
|
||||
expect(buildCodexCommand({ animations: true })).toBe('codex --config tui.animations=true');
|
||||
expect(buildCodexCommand()).toBe('codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote launch command builder', () => {
|
||||
it('wraps codex command overrides in ssh with remote tmux launch', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
@@ -137,7 +146,16 @@ describe('TmuxManager (unit)', () => {
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('exec bash -l');
|
||||
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l');
|
||||
// `failed`, not `on`: `on` also keeps the pane after a CLEAN exit, so typing
|
||||
// `exit` in a remote shell strands a dead pane that the next launch's `-A`
|
||||
// reattaches to instead of starting a shell.
|
||||
expect(command).toContain('remain-on-exit failed');
|
||||
expect(command).not.toContain('remain-on-exit on');
|
||||
// Last in the chain: tmux aborts the rest of a `\;` sequence after an error,
|
||||
// and `failed` needs tmux >= 3.2 on the REMOTE host. Trailing, a rejection
|
||||
// costs only this option instead of every setting after it.
|
||||
expect(command.trimEnd().endsWith("remain-on-exit failed'")).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {
|
||||
@@ -146,7 +164,13 @@ describe('TmuxManager (unit)', () => {
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
||||
// Routed through an interactive login shell so ~/.local/bin (where `claude`
|
||||
// typically lives) is on PATH — ssh's remote-command execution is neither
|
||||
// interactive nor login, so a bare `exec claude` fails with "command not found".
|
||||
// The inner quoting is escaped twice over (once per shellescape() layer), so
|
||||
// assert on the unescaped substrings rather than the literal quoted form.
|
||||
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -88,14 +88,16 @@ describe('TranscriptWatcher', () => {
|
||||
watcher.start(testFile);
|
||||
|
||||
// Add user entry
|
||||
const userEntry = { type: 'user', timestamp: new Date().toISOString(), message: { role: 'user', content: 'test' } };
|
||||
const userEntry = {
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: 'user', content: 'test' },
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(userEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const state = watcher.getState();
|
||||
expect(state.entryCount).toBeGreaterThanOrEqual(1);
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit transcript:complete on result entry', async () => {
|
||||
@@ -109,10 +111,9 @@ describe('TranscriptWatcher', () => {
|
||||
const resultEntry = { type: 'result', timestamp: new Date().toISOString() };
|
||||
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(completeHandler).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(completeHandler).toHaveBeenCalled();
|
||||
});
|
||||
const state = watcher.getState();
|
||||
expect(state.isComplete).toBe(true);
|
||||
});
|
||||
@@ -130,22 +131,62 @@ describe('TranscriptWatcher', () => {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }
|
||||
]
|
||||
}
|
||||
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }],
|
||||
},
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(toolStartHandler).toHaveBeenCalledWith('Read');
|
||||
await vi.waitFor(() => {
|
||||
expect(toolStartHandler).toHaveBeenCalledWith('Read');
|
||||
});
|
||||
const state = watcher.getState();
|
||||
expect(state.toolExecuting).toBe(true);
|
||||
expect(state.currentTool).toBe('Read');
|
||||
});
|
||||
|
||||
it('should complete a tool when Claude writes tool_result in a user entry', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const toolEndHandler = vi.fn();
|
||||
watcher.on('transcript:tool_end', toolEndHandler);
|
||||
|
||||
appendFileSync(
|
||||
testFile,
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'printf done' } }],
|
||||
},
|
||||
}) + '\n'
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.getState().toolExecuting).toBe(true);
|
||||
});
|
||||
|
||||
appendFileSync(
|
||||
testFile,
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done', is_error: false }],
|
||||
},
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(toolEndHandler).toHaveBeenCalledWith('Bash', false);
|
||||
});
|
||||
expect(watcher.getState()).toMatchObject({
|
||||
toolExecuting: false,
|
||||
currentTool: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect plan mode from AskUserQuestion tool', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
@@ -159,17 +200,14 @@ describe('TranscriptWatcher', () => {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }
|
||||
]
|
||||
}
|
||||
content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }],
|
||||
},
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(planModeHandler).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(planModeHandler).toHaveBeenCalled();
|
||||
});
|
||||
const state = watcher.getState();
|
||||
expect(state.planModeDetected).toBe(true);
|
||||
});
|
||||
@@ -182,15 +220,14 @@ describe('TranscriptWatcher', () => {
|
||||
const resultEntry = {
|
||||
type: 'result',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: { type: 'api_error', message: 'Rate limited' }
|
||||
error: { type: 'api_error', message: 'Rate limited' },
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.getState().hasError).toBe(true);
|
||||
});
|
||||
const state = watcher.getState();
|
||||
expect(state.hasError).toBe(true);
|
||||
expect(state.errorMessage).toContain('Rate limited');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user