mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f258a85d | ||
|
|
aa4f423d8a | ||
|
|
1b1057d9e0 | ||
|
|
26cbbe0dcb | ||
|
|
1113d34ca8 | ||
|
|
8a31f10b7d | ||
|
|
2891ae0d6d | ||
|
|
17b86b1007 | ||
|
|
7e357691af | ||
|
|
80e7249a39 | ||
|
|
e0226f7186 | ||
|
|
e8681f575f | ||
|
|
64be4e3029 | ||
|
|
cb7d0ba565 | ||
|
|
22cb563f1e | ||
|
|
f0e13f9fc3 | ||
|
|
bba3d80971 | ||
|
|
67eb5b43eb | ||
|
|
4a4720cb62 | ||
|
|
3c903b36ca | ||
|
|
7c07284b95 |
@@ -52,12 +52,20 @@ jobs:
|
||||
OLD_TAG="aicodeman@${VERSION}"
|
||||
NEW_TAG="codeman@${VERSION}"
|
||||
|
||||
# Update the GitHub release BEFORE deleting the old tag
|
||||
# Update the GitHub release BEFORE deleting the old tag.
|
||||
# make_latest pins the "Latest" badge to the Codeman release. This repo
|
||||
# publishes TWO packages (aicodeman + xterm-zerolag-input), changesets
|
||||
# creates a GitHub release for each, and GitHub awards "Latest" to
|
||||
# whichever was published LAST. That is a race: 1.9.2 kept the badge,
|
||||
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
|
||||
# releases already exist by the time this step runs, so setting it here
|
||||
# is deterministic.
|
||||
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-f tag_name="$NEW_TAG" \
|
||||
-f name="$NEW_TAG"
|
||||
-f name="$NEW_TAG" \
|
||||
-f make_latest=true
|
||||
fi
|
||||
|
||||
# Retag
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# aicodeman
|
||||
|
||||
## 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
|
||||
|
||||
- Background-Bash rewake hook, hooks self-heal that preserves user hooks, and test-harness isolation.
|
||||
- New `PostToolUse(Bash)` hook (PR #176): a self-contained `node -e` helper watches the session transcript for a background command's completion notification and uses Claude Code's `asyncRewake` to wake an idle agent (exit code 2), without injecting terminal input that could submit a user's draft. Works on Claude Code 2.1.207+; older CLIs strip the fields harmlessly.
|
||||
- Hooks self-heal (`refreshStaleHookSecret` renamed to `refreshStaleCodemanHooks`) now replaces only Codeman-owned handlers, preserving user events, matchers, and sibling handlers in mixed configurations; `writeHooksConfig` merges instead of clobbering the hooks key at case creation (PR #176).
|
||||
- Rewake helper hardening: self-terminates on its own 6h deadline and when orphaned; the marker is versioned (V2) with a version-agnostic ownership prefix so future script updates replace older handlers instead of duplicating them.
|
||||
- Hook timeout units fixed: the hook `timeout` field is seconds (the CLI multiplies by 1000), so `HOOK_TIMEOUT_MS = 10000` gave curl hooks a ~2.8-hour effective timeout; now `HOOK_TIMEOUT_SECONDS = 10`.
|
||||
- Test-harness isolation (PR #175): every test file gets a temporary `HOME`/`USERPROFILE` so tests cannot touch real Codeman state or delete real case directories, and `Session` attaches a raw-mode echo PTY instead of a real tmux client under Vitest. Fixes the quick-start suite deleting the real `~/codeman-cases/testcase`.
|
||||
- CI stability: drain console-log rpc forwards before worker teardown (fixes a run-failing `EnvironmentTeardownError` with all tests passing); `test/webview-proxy.test.ts` no longer accidentally runs under the jsdom environment via a directive named in a comment.
|
||||
- Release workflow pins the GitHub "Latest" badge to the Codeman release.
|
||||
|
||||
## 1.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a latent bug where a partial settings PUT silently reset live service state, and trim the `xterm-zerolag-input` README callout.
|
||||
- **`PUT /api/settings` no longer resets watchers on a partial body.** The three `toggleService` calls (subagent watcher, workflow-run watcher, image watcher) read the raw request body with `??` defaults, so every key a caller omitted was treated as "apply the default". A body of just `{statusLineTelemetry:true}` would START the subagent watcher and STOP the workflow and image watchers, undoing the persisted config. They now resolve from `merged` (persisted settings + incoming), the same convention the `tmuxHistoryLimit` branch in that handler already used, so any PUT reconciles services to the effective stored state. Nothing triggered this in practice because every shipped client sends a full settings payload rebuilt from the DOM, but it was a trap for the next partial-update caller.
|
||||
- **Regression test**: `test/routes/system-routes-settings-partial-put.test.ts` (4 cases) pins both directions, omitted keys preserve state and explicit keys still take effect. Verified to fail against the pre-fix handler.
|
||||
- **CLAUDE.md** records the rule under "Adding Features → App setting": anything acting on a setting in that handler must resolve from `merged`, never the request body.
|
||||
- **`xterm-zerolag-input` README**: removed the links line (getcodeman.com / install one-liner / star link) from the Codeman callout above the demo GIF. The callout keeps its links in the heading and body.
|
||||
|
||||
## 1.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Plan-usage chip now defaults ON on desktop, plus the reworked `xterm-zerolag-input` README.
|
||||
- **Plan-usage chip defaults ON (desktop).** The `showPlanUsageLimits` chip (live 5-hour and weekly plan usage from the Claude statusline) used to be opt-in and default OFF, so most users never saw it. Desktop now defaults ON; handhelds still default OFF so the phone header stays minimal and the `mobile-header-buttons-policy` guard keeps passing. Devices with an explicitly stored preference keep whatever they chose, so nobody's OFF gets overridden.
|
||||
- **One resolver behind the chip.** Added `planUsageChipEnabled()` in settings-ui.js and routed all three call sites through it: the App Settings checkbox, the chip's visibility, and the create-time `statusLineTelemetry` flag in session-ui.js. Those three had independent `?? false` / `=== true` defaults, and a chip revealed without the telemetry flag renders `—` forever, so a default flip on one site alone would have shipped a permanently empty chip.
|
||||
- **Cron button comment corrected.** The App Settings comment claimed "Cron button defaults ON" while the code, the template (`btn-cron--hidden`) and the CSS all default it OFF. Verified against a fresh browser profile: the button is hidden and its checkbox unchecked out of the box. Comment now matches, and states why the two halves stay consistent.
|
||||
- **Docs.** CLAUDE.md, `docs/architecture-invariants.md` and `docs/usage-limits-display-plan.md` updated for the new default and the single-resolver rule; the stale `styles.css` comment claiming the server strips the chip's hidden class at render was corrected (display is per-device, so the client reveals it).
|
||||
- **`xterm-zerolag-input` README rework** (0.1.5 shipped the content; this republishes with the graphic and promo changes): replaced the misaligned 8-line keystroke-flow diagram with a two-line stock-vs-zerolag contrast, added a Codeman callout above the demo GIF with links to getcodeman.com and the repo, and rewrote the Origin section so it argues the extraction story instead of repeating the promo.
|
||||
|
||||
## 1.9.2
|
||||
|
||||
### 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.2 (must match `package.json`)
|
||||
**Version**: 1.9.8 (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`.
|
||||
@@ -183,7 +184,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -229,7 +230,11 @@ 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).
|
||||
|
||||
**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. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
@@ -272,7 +277,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).
|
||||
@@ -292,7 +297,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
|
||||
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`).
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
||||
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
|
||||
@@ -320,7 +325,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.ci.config.ts` = same minus the browser/perf excludes — keep the two configs in sync when changing shared options.
|
||||
|
||||
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `test/setup.ts` additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions).
|
||||
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `Session` is test-gated too: instead of attaching a real tmux client, it spawns a raw-mode echo PTY (`TEST_PTY_SCRIPT` in `src/session.ts`), so integration tests get a live input/output loop that echoes each byte exactly once. `test/setup.ts` gives every test file a temporary `HOME`/`USERPROFILE` (all `homedir()`-derived state, `~/.codeman` and `~/codeman-cases` included, resolves into a per-file fixture; the Playwright browser cache path is preserved), and additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions). ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation.
|
||||
|
||||
**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
|
||||
|
||||
@@ -352,6 +357,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
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### Plan-usage chip (statusLine telemetry)
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, revealed by `planUsageChipEnabled()` in settings-ui.js, the single resolver behind the checkbox, the chip and the create-time `statusLineTelemetry` flag) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
|
||||
### Cron jobs
|
||||
|
||||
@@ -206,6 +206,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
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
> Official documentation for Claude Code hooks system, extracted from [code.claude.com](https://code.claude.com/docs/en/hooks).
|
||||
|
||||
**Last Updated**: 2026-01-24
|
||||
**Last Updated**: 2026-07-25
|
||||
**Source**: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)
|
||||
|
||||
> This is a maintained summary, not an exhaustive copy of the upstream reference.
|
||||
> Check the source link for event-specific schemas before adding a new hook.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hooks are automated scripts that execute at specific events during your Claude Code session. They allow you to:
|
||||
|
||||
- Validate, modify, or block tool usage
|
||||
- Add context to prompts
|
||||
- Implement custom workflows
|
||||
@@ -21,12 +25,12 @@ Hooks are automated scripts that execute at specific events during your Claude C
|
||||
|
||||
Hooks are configured in settings files:
|
||||
|
||||
| File | Scope |
|
||||
|------|-------|
|
||||
| `~/.claude/settings.json` | User (global) |
|
||||
| `.claude/settings.json` | Project |
|
||||
| File | Scope |
|
||||
| ----------------------------- | -------------------------- |
|
||||
| `~/.claude/settings.json` | User (global) |
|
||||
| `.claude/settings.json` | Project |
|
||||
| `.claude/settings.local.json` | Local project (gitignored) |
|
||||
| Plugin hook files | Plugin-specific |
|
||||
| Plugin hook files | Plugin-specific |
|
||||
|
||||
### Basic Structure
|
||||
|
||||
@@ -49,8 +53,9 @@ Hooks are configured in settings files:
|
||||
```
|
||||
|
||||
**Key Fields**:
|
||||
|
||||
- `matcher`: Pattern to match tool names (case-sensitive, supports regex like `Edit|Write` or `*` for all)
|
||||
- `type`: `"command"` for bash or `"prompt"` for LLM-based evaluation
|
||||
- `type`: `"command"`, `"http"`, `"mcp_tool"`, `"prompt"`, or `"agent"` where the event supports it
|
||||
- `command`: Bash command to execute
|
||||
- `prompt`: LLM prompt for evaluation (prompt-based hooks only)
|
||||
- `timeout`: Optional timeout in seconds (default: 60)
|
||||
@@ -59,6 +64,10 @@ Hooks are configured in settings files:
|
||||
|
||||
## Hook Events
|
||||
|
||||
Claude Code's current event surface is broader than the detailed subset below. In
|
||||
particular, `TeammateIdle` and `TaskCompleted` are supported lifecycle events used
|
||||
by Codeman; they are not stale or plugin-defined event names.
|
||||
|
||||
### PreToolUse
|
||||
|
||||
**When**: After Claude creates tool parameters, before processing the tool call.
|
||||
@@ -66,15 +75,17 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Approval, denial, or modification of tool calls.
|
||||
|
||||
**Common Matchers**:
|
||||
|
||||
- `Bash` - Shell commands
|
||||
- `Write` - File writing
|
||||
- `Edit` - File editing
|
||||
- `Read` - File reading
|
||||
- `Task` - Subagent tasks
|
||||
- `Agent` - Subagent tasks
|
||||
- `WebFetch`, `WebSearch` - Web operations
|
||||
- `mcp__<server>__<tool>` - MCP tools
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
@@ -96,13 +107,14 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Auto-approve or deny permissions.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow|deny",
|
||||
"updatedInput": { },
|
||||
"updatedInput": {},
|
||||
"message": "deny reason",
|
||||
"interrupt": false
|
||||
}
|
||||
@@ -117,6 +129,7 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Provide feedback, run formatters/linters, log operations.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
@@ -128,15 +141,30 @@ Hooks are configured in settings files:
|
||||
}
|
||||
```
|
||||
|
||||
#### Asynchronous Rewake
|
||||
|
||||
Command hooks can set `"asyncRewake": true` to run asynchronously and wake an
|
||||
idle Claude turn when the hook exits with code 2. The hook's stderr is delivered
|
||||
to Claude as a system reminder. This implies `"async": true`; ordinary async
|
||||
hooks do not wake an idle turn, and their output waits for the next interaction.
|
||||
|
||||
Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts
|
||||
the background task ID from the Bash result, watches the session transcript for
|
||||
the matching completion notification, and exits 2. It does not send terminal
|
||||
input, so it cannot submit a user's partially written prompt.
|
||||
|
||||
### Notification
|
||||
|
||||
**When**: When Claude Code sends notifications.
|
||||
|
||||
**Matchers**:
|
||||
|
||||
- `permission_prompt`
|
||||
- `idle_prompt`
|
||||
- `auth_success`
|
||||
- `elicitation_dialog`
|
||||
- `elicitation_complete`
|
||||
- `elicitation_response`
|
||||
|
||||
### UserPromptSubmit
|
||||
|
||||
@@ -145,6 +173,7 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Add context, validate, or block prompts.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
@@ -165,6 +194,7 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: **Ralph Wiggum loops** - block exit and refeed prompt.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
@@ -173,6 +203,7 @@ Hooks are configured in settings files:
|
||||
```
|
||||
|
||||
Or to allow exit:
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": true,
|
||||
@@ -184,15 +215,32 @@ Or to allow exit:
|
||||
|
||||
### SubagentStop
|
||||
|
||||
**When**: When a subagent (Task tool call) finishes responding.
|
||||
**When**: When a subagent (Agent tool call) finishes responding.
|
||||
|
||||
**Use Cases**: Control nested loops, verify subagent output.
|
||||
|
||||
### TeammateIdle
|
||||
|
||||
**When**: When an agent-team teammate is about to go idle.
|
||||
|
||||
**Use Cases**: Reassign work, continue a teammate loop, or notify an orchestrator.
|
||||
|
||||
**Matcher Support**: None. The hook fires for every occurrence.
|
||||
|
||||
### TaskCompleted
|
||||
|
||||
**When**: When a task is about to be marked completed.
|
||||
|
||||
**Use Cases**: Validate completion or forward team progress to an external UI.
|
||||
|
||||
**Matcher Support**: None. The hook fires for every occurrence.
|
||||
|
||||
### PreCompact
|
||||
|
||||
**When**: Before a compact operation.
|
||||
|
||||
**Matchers**:
|
||||
|
||||
- `manual` - Invoked from `/compact`
|
||||
- `auto` - Invoked from auto-compact
|
||||
|
||||
@@ -201,6 +249,7 @@ Or to allow exit:
|
||||
**When**: When Claude Code starts or resumes a session.
|
||||
|
||||
**Matchers**:
|
||||
|
||||
- `startup` - Fresh start
|
||||
- `resume` - From `--resume`, `--continue`, or `/resume`
|
||||
- `clear` - From `/clear`
|
||||
@@ -209,6 +258,7 @@ Or to allow exit:
|
||||
**Use Cases**: Load development context, set environment variables.
|
||||
|
||||
**Persisting Environment Variables**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
if [ -n "$CLAUDE_ENV_FILE" ]; then
|
||||
@@ -219,6 +269,7 @@ exit 0
|
||||
```
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
@@ -233,6 +284,7 @@ exit 0
|
||||
**When**: When a session ends.
|
||||
|
||||
**Reason Values**:
|
||||
|
||||
- `clear`
|
||||
- `logout`
|
||||
- `prompt_input_exit`
|
||||
@@ -254,7 +306,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
"permission_mode": "default",
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": { },
|
||||
"tool_input": {},
|
||||
"tool_use_id": "toolu_01ABC123..."
|
||||
}
|
||||
```
|
||||
@@ -262,6 +314,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
### Tool-Specific Input
|
||||
|
||||
**Bash**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
@@ -274,6 +327,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
```
|
||||
|
||||
**Write**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Write",
|
||||
@@ -285,6 +339,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
```
|
||||
|
||||
**Edit**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
@@ -302,11 +357,11 @@ Hooks receive JSON via stdin with common fields:
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Behavior |
|
||||
|------|----------|
|
||||
| 0 | Success. `stdout` processed (shown in verbose or added as context) |
|
||||
| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event |
|
||||
| Other | Non-blocking error. `stderr` shown in verbose, execution continues |
|
||||
| Code | Behavior |
|
||||
| ----- | --------------------------------------------------------------------- |
|
||||
| 0 | Success. `stdout` processed (shown in verbose or added as context) |
|
||||
| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event |
|
||||
| Other | Non-blocking error. `stderr` shown in verbose, execution continues |
|
||||
|
||||
### JSON Output (Exit Code 0)
|
||||
|
||||
@@ -323,7 +378,12 @@ Hooks receive JSON via stdin with common fields:
|
||||
|
||||
## Prompt-Based Hooks
|
||||
|
||||
For Stop and SubagentStop events, you can use LLM-based evaluation:
|
||||
Prompt and agent handlers are supported by decision-oriented events including
|
||||
`PreToolUse`, `PermissionRequest`, `PostToolUse`, `PostToolUseFailure`,
|
||||
`PostToolBatch`, `UserPromptSubmit`, `Stop`, `SubagentStop`, `TaskCreated`, and
|
||||
`TaskCompleted`. Check the upstream reference before choosing a handler type.
|
||||
|
||||
For example, a Stop event can use LLM-based evaluation:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -344,6 +404,7 @@ For Stop and SubagentStop events, you can use LLM-based evaluation:
|
||||
```
|
||||
|
||||
**LLM Response Format**:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
@@ -362,17 +423,18 @@ Hooks can be defined in Skills, Agents, and Slash Commands using frontmatter:
|
||||
name: secure-operations
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
- matcher: 'Bash'
|
||||
hooks:
|
||||
- type: command
|
||||
command: "./scripts/security-check.sh"
|
||||
command: './scripts/security-check.sh'
|
||||
---
|
||||
```
|
||||
|
||||
These hooks:
|
||||
|
||||
- Are scoped to the component's lifecycle
|
||||
- Only run when that component is active
|
||||
- Support: PreToolUse, PostToolUse, Stop
|
||||
- Support all hook events; a subagent-scoped `Stop` is converted to `SubagentStop`
|
||||
|
||||
---
|
||||
|
||||
@@ -550,11 +612,11 @@ exit 0
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CLAUDE_PROJECT_DIR` | Project root directory |
|
||||
| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI |
|
||||
| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) |
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| `CLAUDE_PROJECT_DIR` | Project root directory |
|
||||
| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI |
|
||||
| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) |
|
||||
|
||||
---
|
||||
|
||||
@@ -593,4 +655,4 @@ Use `/hooks` command to view registered hooks and make changes.
|
||||
|
||||
---
|
||||
|
||||
*Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)*
|
||||
_Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)_
|
||||
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan Usage Limits Display — Design & As-Built
|
||||
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
>
|
||||
> Two surfaces from one `statusLine` callback:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
|
||||
@@ -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.2",
|
||||
"version": "1.9.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -12333,7 +12333,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.8",
|
||||
"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,55 @@
|
||||
# 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
|
||||
|
||||
- Fix a latent bug where a partial settings PUT silently reset live service state, and trim the `xterm-zerolag-input` README callout.
|
||||
- **`PUT /api/settings` no longer resets watchers on a partial body.** The three `toggleService` calls (subagent watcher, workflow-run watcher, image watcher) read the raw request body with `??` defaults, so every key a caller omitted was treated as "apply the default". A body of just `{statusLineTelemetry:true}` would START the subagent watcher and STOP the workflow and image watchers, undoing the persisted config. They now resolve from `merged` (persisted settings + incoming), the same convention the `tmuxHistoryLimit` branch in that handler already used, so any PUT reconciles services to the effective stored state. Nothing triggered this in practice because every shipped client sends a full settings payload rebuilt from the DOM, but it was a trap for the next partial-update caller.
|
||||
- **Regression test**: `test/routes/system-routes-settings-partial-put.test.ts` (4 cases) pins both directions, omitted keys preserve state and explicit keys still take effect. Verified to fail against the pre-fix handler.
|
||||
- **CLAUDE.md** records the rule under "Adding Features → App setting": anything acting on a setting in that handler must resolve from `merged`, never the request body.
|
||||
- **`xterm-zerolag-input` README**: removed the links line (getcodeman.com / install one-liner / star link) from the Codeman callout above the demo GIF. The callout keeps its links in the heading and body.
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Plan-usage chip now defaults ON on desktop, plus the reworked `xterm-zerolag-input` README.
|
||||
- **Plan-usage chip defaults ON (desktop).** The `showPlanUsageLimits` chip (live 5-hour and weekly plan usage from the Claude statusline) used to be opt-in and default OFF, so most users never saw it. Desktop now defaults ON; handhelds still default OFF so the phone header stays minimal and the `mobile-header-buttons-policy` guard keeps passing. Devices with an explicitly stored preference keep whatever they chose, so nobody's OFF gets overridden.
|
||||
- **One resolver behind the chip.** Added `planUsageChipEnabled()` in settings-ui.js and routed all three call sites through it: the App Settings checkbox, the chip's visibility, and the create-time `statusLineTelemetry` flag in session-ui.js. Those three had independent `?? false` / `=== true` defaults, and a chip revealed without the telemetry flag renders `—` forever, so a default flip on one site alone would have shipped a permanently empty chip.
|
||||
- **Cron button comment corrected.** The App Settings comment claimed "Cron button defaults ON" while the code, the template (`btn-cron--hidden`) and the CSS all default it OFF. Verified against a fresh browser profile: the button is hidden and its checkbox unchecked out of the box. Comment now matches, and states why the two halves stay consistent.
|
||||
- **Docs.** CLAUDE.md, `docs/architecture-invariants.md` and `docs/usage-limits-display-plan.md` updated for the new default and the single-resolver rule; the stale `styles.css` comment claiming the server strips the chip's hidden class at render was corrected (display is per-device, so the client reveals it).
|
||||
- **`xterm-zerolag-input` README rework** (0.1.5 shipped the content; this republishes with the graphic and promo changes): replaced the misaligned 8-line keystroke-flow diagram with a two-line stock-vs-zerolag contrast, added a Codeman callout above the demo GIF with links to getcodeman.com and the repo, and rewrote the Origin section so it argues the extraction story instead of repeating the promo.
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
</p>
|
||||
</p>
|
||||
|
||||
> ### Made for [**Codeman**](https://getcodeman.com)
|
||||
>
|
||||
> This overlay is the local echo engine of [**Codeman**](https://github.com/Ark0N/Codeman), mission control for AI coding agents: run and monitor a dozen Claude Code, Codex, OpenCode and Gemini sessions at once, watch their subagents work in live floating windows, let them run autonomously overnight, and drive all of it from your phone.
|
||||
>
|
||||
> That last part is why this library exists. The demo below is a real Codeman session on two phones.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/zerolag-demo-20260728.gif" alt="Side-by-side phones typing into the same remote session: with zerolag the text appears at 0ms, without it every keystroke waits 600ms to 2.7s for the server echo" width="900">
|
||||
</p>
|
||||
@@ -29,18 +35,14 @@
|
||||
|
||||
Over a remote connection, xterm.js shows you a character only after it has flown to the server and back. At 100-500ms RTT that reads as broken: you type ahead of the screen, you cannot see your typos, and you start pecking one key at a time to stay in sync.
|
||||
|
||||
`xterm-zerolag-input` paints your keystrokes **immediately**, as an absolutely-positioned DOM overlay locked to the terminal's character grid. When the server echo lands 300ms later, the overlay clears and the real terminal text takes over. The handoff is invisible.
|
||||
```
|
||||
stock xterm.js keypress ─────── 300 ms ───────→ character appears
|
||||
with zerolag keypress → character appears · echo lands later, unseen
|
||||
```
|
||||
|
||||
```
|
||||
┌─── DOM overlay (instant, 0ms)
|
||||
User types 'h' ─── onData('h') ───┤
|
||||
└─── Your app sends to PTY ──→ Server
|
||||
│
|
||||
Server echoes 'h' ←──────────────────────────────────────────────────┘
|
||||
│ (200-500ms RTT)
|
||||
└──→ terminal.write('h') ──→ overlay.clear()
|
||||
(server output replaces overlay, seamless transition)
|
||||
```
|
||||
Same keystroke, same link. The only difference is who you wait for: the server, or nobody.
|
||||
|
||||
`xterm-zerolag-input` paints your keystrokes **immediately**, as an absolutely-positioned DOM overlay locked to the terminal's character grid. The byte still goes to the PTY exactly as before, so nothing about your shell changes. When the server echo lands 300ms later, the overlay clears and the real terminal text takes over on the same pixels. The handoff is invisible.
|
||||
|
||||
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
||||
|
||||
@@ -54,7 +56,7 @@ Server echoes 'h' ←───────────────────
|
||||
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
|
||||
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
|
||||
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from a production app, hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
|
||||
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
|
||||
|
||||
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
|
||||
|
||||
@@ -422,9 +424,11 @@ The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and r
|
||||
|
||||
## Origin
|
||||
|
||||
Extracted from [**Codeman**](https://github.com/Ark0N/Codeman), mission control for AI coding agents: multi-session management, live agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, Codex and Gemini.
|
||||
[Codeman](https://getcodeman.com) needed this before anyone else did. A coding agent you drive from your phone over a tunnel is unusable if every keystroke costs a round trip.
|
||||
|
||||
The local echo system was built to make phone and remote access feel instant, ran in production for thousands of hours, then survived three deep code audits before being pulled out into this standalone library. The demo above is a real Codeman session on two phones.
|
||||
So the overlay was built there, ran in production for thousands of hours, and survived three deep code audits before being pulled out into this standalone library with its tests intact. Nothing was reimplemented for the extraction: the engine here is the one Codeman ships.
|
||||
|
||||
Want the whole thing? [**getcodeman.com**](https://getcodeman.com) · [github.com/Ark0N/Codeman](https://github.com/Ark0N/Codeman)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.1.5",
|
||||
"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'));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -31,5 +31,10 @@ export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
/** Timeout for Claude Code hook curl commands (ms) */
|
||||
export const HOOK_TIMEOUT_MS = 10000;
|
||||
/**
|
||||
* Timeout for Claude Code hook curl commands, in SECONDS: the hook `timeout`
|
||||
* field is seconds (the CLI multiplies by 1000). The predecessor constant
|
||||
* `HOOK_TIMEOUT_MS = 10000` fed the same field, so those hooks effectively had a
|
||||
* ~2.8-hour timeout; 10 seconds is the originally intended budget.
|
||||
*/
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+208
-21
@@ -16,9 +16,9 @@
|
||||
* `stop`, `teammate_idle`, `task_completed`
|
||||
*
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1),
|
||||
* `TaskCompleted` (1)
|
||||
* `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake)
|
||||
*
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS)
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS)
|
||||
* @consumedby web/server (session creation), session-cli-builder (env setup)
|
||||
*
|
||||
* @module hooks-config
|
||||
@@ -29,7 +29,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
@@ -40,6 +40,104 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
||||
* are independent; the map self-prunes when a path's chain goes idle.
|
||||
*/
|
||||
const settingsWriteLocks = new Map<string, Promise<unknown>>();
|
||||
/**
|
||||
* Version-agnostic ownership prefix: every rewake script version embeds a marker
|
||||
* starting with this, and `isCodemanHookHandler` matches on the prefix. That way a
|
||||
* version bump replaces the old handler instead of duplicating it (matching on the
|
||||
* full versioned marker would disown every older script).
|
||||
*/
|
||||
const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V';
|
||||
/**
|
||||
* Current script version. Bump the suffix whenever `generateBackgroundWakeScript`
|
||||
* changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as
|
||||
* stale, so healed cases pick up the new script on next launch.
|
||||
*/
|
||||
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}2`;
|
||||
const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Inline Node helper for Claude Code's `asyncRewake` hook.
|
||||
*
|
||||
* A background Bash tool returns immediately with a task ID, then Claude writes
|
||||
* its completion as a queue-operation in the transcript. Watching that durable
|
||||
* record avoids injecting terminal input (which could submit a user's draft).
|
||||
* The helper is embedded in settings via `node -e`, so it has no script path
|
||||
* that can go stale after an install or plugin-cache cleanup.
|
||||
*
|
||||
* Self-terminating: Claude Code enforces the hook timeout, but the helper does not
|
||||
* rely on it. It exits on its own deadline (same budget) and when orphaned
|
||||
* (`ppid === 1`), so a dead session cannot leave a poller stat-ing the transcript
|
||||
* forever. The ppid check misses subreaper setups; the deadline is the backstop.
|
||||
*/
|
||||
export function generateBackgroundWakeScript(): string {
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
`const ${BACKGROUND_WAKE_MARKER} = true;`,
|
||||
`const deadline = Date.now() + ${BACKGROUND_WAKE_TIMEOUT_SECONDS} * 1000;`,
|
||||
'let input = {};',
|
||||
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
|
||||
'function findTaskId(value) {',
|
||||
" const idKeys = new Set(['taskId', 'task_id', 'shellId', 'shell_id', 'backgroundTaskId', 'background_task_id']);",
|
||||
' const stack = [value];',
|
||||
' const seen = new Set();',
|
||||
' while (stack.length > 0) {',
|
||||
' const current = stack.pop();',
|
||||
" if (!current || typeof current !== 'object' || seen.has(current)) continue;",
|
||||
' seen.add(current);',
|
||||
' for (const [key, nested] of Object.entries(current)) {',
|
||||
" if (idKeys.has(key) && typeof nested === 'string' && /^[A-Za-z0-9_-]+$/.test(nested)) return nested;",
|
||||
" if (nested && typeof nested === 'object') stack.push(nested);",
|
||||
' }',
|
||||
' }',
|
||||
" const serialized = JSON.stringify(value ?? '');",
|
||||
' const messageMatch = serialized.match(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/i);',
|
||||
' if (messageMatch) return messageMatch[1];',
|
||||
' const pathMatch = serialized.match(/[\\\\/]tasks[\\\\/]([A-Za-z0-9_-]+)\\.output/i);',
|
||||
' return pathMatch ? pathMatch[1] : null;',
|
||||
'}',
|
||||
'const taskId = findTaskId(input.tool_response);',
|
||||
"const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';",
|
||||
'if (!taskId || !transcriptPath) process.exit(0);',
|
||||
'let position = 0;',
|
||||
'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }',
|
||||
"let carry = '';",
|
||||
'function inspect(text) {',
|
||||
' for (const line of text.split(/\\r?\\n/)) {',
|
||||
' if (!line.includes(taskId)) continue;',
|
||||
' let entry;',
|
||||
' try { entry = JSON.parse(line); } catch { continue; }',
|
||||
" if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;",
|
||||
" if (!entry.content.includes('<task-id>' + taskId + '</task-id>')) continue;",
|
||||
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
|
||||
' if (!status) continue;',
|
||||
' const output = entry.content.match(/<output-file>([^<]+)<\\/output-file>/i);',
|
||||
" const location = output ? ' Read ' + output[1] + ' and' : '';",
|
||||
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');",
|
||||
' process.exit(2);',
|
||||
' }',
|
||||
'}',
|
||||
'function poll() {',
|
||||
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
|
||||
' try {',
|
||||
' const size = fs.statSync(transcriptPath).size;',
|
||||
" if (size < position) { position = 0; carry = ''; }",
|
||||
' if (size > position) {',
|
||||
' const length = Math.min(size - position, 1048576);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(transcriptPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, position);',
|
||||
' fs.closeSync(fd);',
|
||||
' position += bytes;',
|
||||
" carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
|
||||
' inspect(carry);',
|
||||
' }',
|
||||
' } catch {}',
|
||||
' setTimeout(poll, 1000);',
|
||||
'}',
|
||||
'poll();',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
|
||||
@@ -86,36 +184,118 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
Notification: [
|
||||
{
|
||||
matcher: 'idle_prompt',
|
||||
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
{
|
||||
matcher: 'permission_prompt',
|
||||
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
{
|
||||
matcher: 'elicitation_dialog',
|
||||
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
TeammateIdle: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
TaskCompleted: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', generateBackgroundWakeScript()],
|
||||
asyncRewake: true,
|
||||
timeout: BACKGROUND_WAKE_TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isCodemanHookHandler(value: unknown): boolean {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
// Prefix, not the versioned marker: older script versions must still be ours.
|
||||
return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace only Codeman-owned command handlers while preserving user events,
|
||||
* matcher entries, and sibling handlers in mixed entries.
|
||||
*/
|
||||
function mergeCodemanHooks(existingValue: unknown, generated: Record<string, unknown[]>): Record<string, unknown[]> {
|
||||
const existing =
|
||||
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? (existingValue as Record<string, unknown>)
|
||||
: {};
|
||||
const merged: Record<string, unknown[]> = {};
|
||||
|
||||
for (const eventName of new Set([...Object.keys(existing), ...Object.keys(generated)])) {
|
||||
const existingEntries = Array.isArray(existing[eventName]) ? (existing[eventName] as unknown[]) : [];
|
||||
const generatedEntries = generated[eventName];
|
||||
if (!generatedEntries) {
|
||||
merged[eventName] = existingEntries;
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries: unknown[] = [];
|
||||
let insertedGenerated = false;
|
||||
for (const entry of existingEntries) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
if (!isCodemanHookHandler(entry)) entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
if (!Array.isArray(record.hooks)) {
|
||||
if (isCodemanHookHandler(record)) {
|
||||
if (!insertedGenerated) {
|
||||
entries.push(...generatedEntries);
|
||||
insertedGenerated = true;
|
||||
}
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const retainedHandlers = record.hooks.filter((handler) => !isCodemanHookHandler(handler));
|
||||
const removedCodemanHandler = retainedHandlers.length !== record.hooks.length;
|
||||
if (removedCodemanHandler && !insertedGenerated) {
|
||||
entries.push(...generatedEntries);
|
||||
insertedGenerated = true;
|
||||
}
|
||||
if (retainedHandlers.length > 0 || !removedCodemanHandler) {
|
||||
entries.push(retainedHandlers.length === record.hooks.length ? entry : { ...record, hooks: retainedHandlers });
|
||||
}
|
||||
}
|
||||
|
||||
if (!insertedGenerated) entries.push(...generatedEntries);
|
||||
merged[eventName] = entries;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subset of env keys from .claude/settings.local.json.env if present.
|
||||
* Used during the disk→tmux-setenv migration: when the caller is actively setting
|
||||
@@ -237,29 +417,31 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
const merged = {
|
||||
...existing,
|
||||
hooks: mergeCodemanHooks(existing.hooks, hooksConfig.hooks),
|
||||
};
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
|
||||
* accepting its hook events.
|
||||
* Self-heal a case's Codeman-owned hooks block.
|
||||
*
|
||||
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
|
||||
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
|
||||
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
|
||||
* install. This refreshes the hooks block so those stale curls regain the header.
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
|
||||
* Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either
|
||||
* stale shape on launch so existing cases gain both current behaviors.
|
||||
*
|
||||
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
|
||||
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
|
||||
* them), when the hooks aren't ours, or when the secret is already present — so it never
|
||||
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
|
||||
* when the file/hooks are absent (we never impose hooks on a user who removed them) or
|
||||
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
||||
*/
|
||||
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
|
||||
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
if (!existsSync(settingsPath)) return;
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
@@ -274,8 +456,13 @@ export async function refreshStaleHookSecret(casePath: string): Promise<void> {
|
||||
// The generated curl carries this header literal (see generateHooksConfig); its
|
||||
// absence on our own hooks means they predate COD-54 and need regenerating.
|
||||
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
|
||||
if (!isOurs || hasSecret) return;
|
||||
const merged = { ...existing, ...generateHooksConfig() };
|
||||
const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER);
|
||||
if (!isOurs || (hasSecret && hasBackgroundWake)) return;
|
||||
const generated = generateHooksConfig();
|
||||
const merged = {
|
||||
...existing,
|
||||
hooks: mergeCodemanHooks(existing.hooks, generated.hooks),
|
||||
};
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -68,6 +68,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
antigravity: 'exec agy',
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
+82
-39
@@ -49,6 +49,7 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -65,6 +66,8 @@ import {
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -140,7 +143,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 +154,8 @@ function getModeLabel(mode: SessionMode): string {
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'antigravity':
|
||||
return 'Antigravity';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
@@ -180,6 +185,13 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
/**
|
||||
* Echo transport for the test-mode PTY attach. Raw mode disables the tty line
|
||||
* discipline, so each input byte flows back exactly once and immediately; without
|
||||
* it, tty echo doubles every line and canonical buffering holds bytes until Enter.
|
||||
*/
|
||||
const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);';
|
||||
/** Delay before the in-container Claude CLI version probe (lets the container start). */
|
||||
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
@@ -396,6 +408,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
|
||||
@@ -482,6 +496,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) */
|
||||
@@ -555,6 +571,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)
|
||||
@@ -1104,6 +1125,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
|
||||
@@ -1248,24 +1270,33 @@ export class Session extends EventEmitter {
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
// Prevent tmux from letting the newest browser attach dictate global window
|
||||
// size; accepted Codeman resize events update it explicitly below.
|
||||
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
||||
// Integration tests need a live input/output transport without attaching to
|
||||
// the host's tmux server or agent CLI. Production still uses the real mux.
|
||||
if (!IS_TEST_MODE) {
|
||||
// Prevent tmux from letting the newest browser attach dictate global window
|
||||
// size; accepted Codeman resize events update it explicitly below.
|
||||
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
||||
}
|
||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
|
||||
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
|
||||
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
|
||||
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||
const { cols: ptyCols, rows: ptyRows } = IS_TEST_MODE
|
||||
? { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS }
|
||||
: queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||
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(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
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}`);
|
||||
@@ -1329,6 +1360,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,
|
||||
@@ -1501,6 +1533,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,
|
||||
@@ -1582,18 +1615,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';
|
||||
@@ -1916,13 +1955,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';
|
||||
@@ -2022,14 +2063,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(
|
||||
@@ -2683,7 +2726,7 @@ export class Session extends EventEmitter {
|
||||
if (this.ptyProcess && (dimsChanged || options.force)) {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (this._mux && this._muxSession) {
|
||||
if (!IS_TEST_MODE && this._mux && this._muxSession) {
|
||||
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
|
||||
}
|
||||
this.ptyProcess.resize(cols, rows);
|
||||
|
||||
+53
-2
@@ -43,6 +43,7 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
resolveOpenCodeDir,
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
resolveAntigravityDir,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
@@ -682,6 +684,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 +739,7 @@ export function buildSpawnCommand(options: {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
}): string {
|
||||
@@ -739,6 +770,9 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'gemini') {
|
||||
return buildGeminiCommand(options.geminiConfig);
|
||||
}
|
||||
if (options.mode === 'antigravity') {
|
||||
return buildAntigravityCommand(options.antigravityConfig);
|
||||
}
|
||||
return '$SHELL';
|
||||
}
|
||||
|
||||
@@ -917,6 +951,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 +1521,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 +1607,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 +1658,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1667,6 +1710,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 +1727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -1901,6 +1950,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1938,6 +1988,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;
|
||||
|
||||
+23
-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';
|
||||
@@ -312,6 +319,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,6 +470,8 @@ 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) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -59,6 +59,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;
|
||||
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ 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 { 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;
|
||||
}
|
||||
+29
-3
@@ -800,6 +800,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 +1567,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 +1984,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');
|
||||
@@ -3443,6 +3453,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 +3549,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 +3586,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 +4120,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 +4634,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': '会话名称',
|
||||
|
||||
@@ -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) -->
|
||||
@@ -464,6 +470,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
|
||||
@@ -733,6 +742,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 +1286,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 +1448,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">
|
||||
@@ -1848,7 +1882,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 +2660,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,684 @@
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
_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) {
|
||||
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%;
|
||||
@@ -2245,6 +2259,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 +2727,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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -503,7 +509,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';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -716,7 +722,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// is shared by sibling sessions, so create-with-false must not yank it
|
||||
// — see the comment in session-routes create). Disabling the setting
|
||||
// removes it via the App Settings toggle path (system-routes), not here.
|
||||
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
|
||||
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
@@ -1076,6 +1082,57 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
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';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
|
||||
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.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Options Modal
|
||||
@@ -1088,7 +1145,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 +1175,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 +2595,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;
|
||||
@@ -325,9 +329,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
// 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).
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
@@ -408,7 +422,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;
|
||||
@@ -1447,6 +1461,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,
|
||||
@@ -1587,7 +1602,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
audio: document.getElementById('eventSubagentAudio').checked,
|
||||
},
|
||||
},
|
||||
_version: 4,
|
||||
_version: 5,
|
||||
};
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.preferences = notifPrefsToSave;
|
||||
@@ -1610,6 +1625,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');
|
||||
|
||||
@@ -1640,6 +1659,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 {
|
||||
@@ -1798,6 +1819,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
@@ -1805,6 +1829,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
|
||||
@@ -1889,6 +1917,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
|
||||
// and the mobile-header-buttons-policy guard depends on that staying false).
|
||||
// Single source of truth for THREE call sites that must never disagree: the
|
||||
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry
|
||||
// flag sent on session create. A chip shown without telemetry renders "—"
|
||||
// forever, which is exactly the drift this helper prevents.
|
||||
planUsageChipEnabled(settings = null) {
|
||||
const s = settings ?? this.loadAppSettingsFromStorage();
|
||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -1967,11 +2007,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
// !important), matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
|
||||
// Settings → Display → "Plan Usage Limits"). The template always ships it
|
||||
// hidden because display is per-device and the server cannot know a
|
||||
// localStorage value, so THIS is what reveals it on every load as well as
|
||||
// on a live toggle. Marker class (base is display:inline-flex !important),
|
||||
// matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = this.planUsageChipEnabled(settings);
|
||||
const planUsageChip = document.getElementById('planUsageChip');
|
||||
if (planUsageChip) {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
@@ -2246,10 +2288,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'mobileOverviewEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
// server.json may still carry `true` — drop it so the server value is NEVER
|
||||
// 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
|
||||
// used to sync, so an older server.json may still carry a value — drop it
|
||||
// so the server value is NEVER
|
||||
// seeded into a device that didn't explicitly enable it (collection is handled
|
||||
// separately via the statusLineTelemetry action, not this display flag).
|
||||
delete appSettings.showPlanUsageLimits;
|
||||
@@ -2275,7 +2319,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();
|
||||
}
|
||||
}
|
||||
|
||||
+696
-4
@@ -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;
|
||||
@@ -10248,9 +10939,10 @@ kbd {
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
setting is on and the client toggles it live on save. */
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
|
||||
hidden via the marker class below because display is PER-DEVICE and the
|
||||
server cannot read localStorage; settings-ui.js reveals it on load (desktop
|
||||
default ON, handhelds OFF) and on a live toggle. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1178,6 +1178,19 @@ 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');
|
||||
@@ -1191,6 +1204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
applyStatusLineConfig,
|
||||
refreshStaleHookSecret,
|
||||
refreshStaleCodemanHooks,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.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 + '/'));
|
||||
@@ -445,7 +457,7 @@ export function registerSessionRoutes(
|
||||
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
|
||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleHookSecret(workingDir).catch(() => {});
|
||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
@@ -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
|
||||
) {
|
||||
@@ -1943,6 +1968,7 @@ export function registerSessionRoutes(
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
@@ -1988,6 +2014,7 @@ export function registerSessionRoutes(
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2018,6 +2045,7 @@ export function registerSessionRoutes(
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2110,6 +2138,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 +2195,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);
|
||||
}
|
||||
|
||||
@@ -2170,14 +2209,21 @@ export function registerSessionRoutes(
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
// 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();
|
||||
@@ -2186,7 +2232,7 @@ export function registerSessionRoutes(
|
||||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — the session still runs, hooks may be degraded */
|
||||
@@ -2206,6 +2252,7 @@ export function registerSessionRoutes(
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
@@ -2224,17 +2271,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 +2299,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,
|
||||
|
||||
@@ -374,7 +374,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode, Codex, Gemini)
|
||||
// CLI Integrations (OpenCode, Codex, Gemini, Antigravity)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== OpenCode ==========
|
||||
@@ -405,6 +405,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)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -692,20 +702,27 @@ export function registerSystemRoutes(
|
||||
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||
}
|
||||
|
||||
// Service toggles resolve from `merged` (existing + incoming), NEVER from the
|
||||
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and
|
||||
// reading the body directly turned every omission into "apply the default":
|
||||
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher
|
||||
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
|
||||
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile
|
||||
// services to the effective stored settings instead, which also self-heals
|
||||
// drift. Same convention as the tmuxHistoryLimit block above.
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
toggleService(
|
||||
((settings.showUltracodeAgents as boolean) ?? false) ||
|
||||
((settings.ultracodeFloatingWindows as boolean) ?? false),
|
||||
((merged.showUltracodeAgents as boolean) ?? false) || ((merged.ultracodeFloatingWindows as boolean) ?? false),
|
||||
workflowRunWatcher,
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled) {
|
||||
|
||||
+25
-5
@@ -86,7 +86,7 @@ export const FilesystemPreviewQuerySchema = z.object({
|
||||
// ========== 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 +116,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.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -206,9 +206,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 +237,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 +346,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 +619,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,
|
||||
@@ -957,7 +977,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']),
|
||||
|
||||
@@ -2461,6 +2461,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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');
|
||||
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec agy');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* COD-91 — `refreshStaleHookSecret` self-heal.
|
||||
* COD-91 — `refreshStaleCodemanHooks` self-heal.
|
||||
*
|
||||
* Making the hook-event secret unconditionally required (PR #127) would silently 401 the
|
||||
* hook curls baked into cases created before the secret header existed (COD-54). Those
|
||||
* curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case
|
||||
* CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the
|
||||
* CREATION, so existing cases never refresh. `refreshStaleCodemanHooks` regenerates the
|
||||
* hooks block on session spawn — but ONLY when the case already holds Codeman's own
|
||||
* pre-secret hook curls, never clobbering a user's customizations.
|
||||
*
|
||||
@@ -15,7 +15,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { refreshStaleHookSecret } from '../src/hooks-config.js';
|
||||
import { refreshStaleCodemanHooks } from '../src/hooks-config.js';
|
||||
|
||||
const SECRET_HEADER = 'X-Codeman-Hook-Secret';
|
||||
|
||||
@@ -41,7 +41,7 @@ function staleCodemanHooks() {
|
||||
};
|
||||
}
|
||||
|
||||
describe('refreshStaleHookSecret', () => {
|
||||
describe('refreshStaleCodemanHooks', () => {
|
||||
let dir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('refreshStaleHookSecret', () => {
|
||||
settingsPath,
|
||||
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2)
|
||||
);
|
||||
await refreshStaleHookSecret(dir);
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||||
@@ -73,11 +73,11 @@ describe('refreshStaleHookSecret', () => {
|
||||
it('leaves a hooks block that already carries the secret unchanged', async () => {
|
||||
// Seed with a current block by healing a stale one first, then re-heal: second pass must no-op.
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
|
||||
await refreshStaleHookSecret(dir);
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
const healed = readFileSync(settingsPath, 'utf-8');
|
||||
expect(healed).toContain(SECRET_HEADER);
|
||||
|
||||
await refreshStaleHookSecret(dir);
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
|
||||
});
|
||||
|
||||
@@ -88,19 +88,60 @@ describe('refreshStaleHookSecret', () => {
|
||||
2
|
||||
);
|
||||
writeFileSync(settingsPath, foreign);
|
||||
await refreshStaleHookSecret(dir);
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign);
|
||||
});
|
||||
|
||||
it('preserves user handlers and events in a mixed stale configuration', async () => {
|
||||
const hooks = staleCodemanHooks();
|
||||
hooks.Stop[0].hooks.push({
|
||||
type: 'command',
|
||||
command: './notify-user.sh',
|
||||
timeout: 10,
|
||||
});
|
||||
const customPostToolUse = {
|
||||
matcher: 'Write',
|
||||
hooks: [{ type: 'command', command: './format.sh' }],
|
||||
};
|
||||
const customEvent = [
|
||||
{
|
||||
hooks: [{ type: 'command', command: './audit.sh' }],
|
||||
},
|
||||
];
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
...hooks,
|
||||
PostToolUse: [customPostToolUse],
|
||||
CustomEvent: customEvent,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V');
|
||||
expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh');
|
||||
expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse]));
|
||||
expect(after.hooks.CustomEvent).toEqual(customEvent);
|
||||
});
|
||||
|
||||
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
|
||||
await refreshStaleHookSecret(dir);
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a malformed settings file untouched', async () => {
|
||||
const garbage = '{ not valid json';
|
||||
writeFileSync(settingsPath, garbage);
|
||||
await refreshStaleHookSecret(dir);
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
|
||||
});
|
||||
});
|
||||
|
||||
+180
-6
@@ -9,7 +9,13 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { generateHooksConfig, writeHooksConfig } from '../src/hooks-config.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
refreshStaleCodemanHooks,
|
||||
writeHooksConfig,
|
||||
} from '../src/hooks-config.js';
|
||||
|
||||
describe('generateHooksConfig', () => {
|
||||
it('should return an object with hooks key', () => {
|
||||
@@ -29,6 +35,30 @@ describe('generateHooksConfig', () => {
|
||||
expect(config.hooks.Stop).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should configure a self-contained Bash background-task rewake hook', () => {
|
||||
const config = generateHooksConfig();
|
||||
const postToolHooks = config.hooks.PostToolUse as Array<{
|
||||
matcher: string;
|
||||
hooks: Array<{
|
||||
type: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
asyncRewake: boolean;
|
||||
timeout: number;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
expect(postToolHooks).toHaveLength(1);
|
||||
expect(postToolHooks[0].matcher).toBe('Bash');
|
||||
expect(postToolHooks[0].hooks[0]).toMatchObject({
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
asyncRewake: true,
|
||||
});
|
||||
expect(postToolHooks[0].hooks[0].args).toEqual(['-e', generateBackgroundWakeScript()]);
|
||||
expect(postToolHooks[0].hooks[0].timeout).toBeGreaterThanOrEqual(3600);
|
||||
});
|
||||
|
||||
it('should configure idle_prompt matcher', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||
@@ -65,10 +95,10 @@ describe('generateHooksConfig', () => {
|
||||
expect(notifHooks[0].hooks[0].command).toContain('|| true');
|
||||
});
|
||||
|
||||
it('should set timeout to 10000ms', () => {
|
||||
it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>;
|
||||
expect(notifHooks[0].hooks[0].timeout).toBe(10000);
|
||||
expect(notifHooks[0].hooks[0].timeout).toBe(10);
|
||||
});
|
||||
|
||||
it('should include correct event names in curl payloads', () => {
|
||||
@@ -157,7 +187,75 @@ describe('writeHooksConfig', () => {
|
||||
expect(parsed.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('should overwrite existing hooks key', async () => {
|
||||
it('should upgrade Codeman-owned hooks that predate background rewake', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const oldHooks = generateHooksConfig().hooks;
|
||||
delete oldHooks.PostToolUse;
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: oldHooks }, null, 2));
|
||||
|
||||
await refreshStaleCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V');
|
||||
});
|
||||
|
||||
it('should replace an older rewake script version without duplicating it', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
// Simulate a case healed by the previous release: current curls (secret present)
|
||||
// plus a V1 rewake handler. The version bump must swap the handler in place.
|
||||
const hooks = generateHooksConfig().hooks;
|
||||
hooks.PostToolUse = [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', 'const CODEMAN_BACKGROUND_REWAKE_V1 = true; process.exit(0);'],
|
||||
asyncRewake: true,
|
||||
timeout: 21600,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks }, null, 2));
|
||||
|
||||
await refreshStaleCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const serialized = JSON.stringify(parsed.hooks.PostToolUse);
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(parsed.hooks.PostToolUse[0].hooks).toHaveLength(1);
|
||||
expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V2');
|
||||
expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1');
|
||||
});
|
||||
|
||||
it('should not add rewake hooks to a user-owned hook configuration', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const userHooks = {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Write',
|
||||
hooks: [{ type: 'command', command: './format.sh' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks }, null, 2));
|
||||
|
||||
await refreshStaleCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.hooks).toEqual(userHooks);
|
||||
});
|
||||
|
||||
it('should preserve user hook events while installing Codeman hooks', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2));
|
||||
@@ -165,7 +263,7 @@ describe('writeHooksConfig', () => {
|
||||
await writeHooksConfig(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
|
||||
expect(parsed.hooks.oldHook).toBeUndefined();
|
||||
expect(parsed.hooks.oldHook).toEqual([]);
|
||||
expect(parsed.hooks.Notification).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -187,6 +285,82 @@ describe('writeHooksConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('background task rewake helper', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-background-rewake-test-' + Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runHelper(input: Record<string, unknown>): Promise<{ code: number | null; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['-e', generateBackgroundWakeScript()], {
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error('background rewake helper timed out'));
|
||||
}, 5000);
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, stderr });
|
||||
});
|
||||
child.stdin.end(JSON.stringify(input));
|
||||
});
|
||||
}
|
||||
|
||||
it('exits without waiting for an ordinary Bash result', async () => {
|
||||
const result = await runHelper({
|
||||
transcript_path: join(testDir, 'transcript.jsonl'),
|
||||
tool_response: { stdout: 'ordinary command completed' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('exits 2 when the matching background command completes', async () => {
|
||||
const transcriptPath = join(testDir, 'transcript.jsonl');
|
||||
writeFileSync(transcriptPath, '');
|
||||
|
||||
const resultPromise = runHelper({
|
||||
transcript_path: transcriptPath,
|
||||
tool_response: {
|
||||
stdout: 'Command running in background with ID: bg-test-1. Output is being written to: /tmp/bg-test-1.output.',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
content:
|
||||
'<task-notification>\n<task-id>bg-test-1</task-id>\n<status>completed</status>\n' +
|
||||
'<output-file>/tmp/bg-test-1.output</output-file>\n</task-notification>',
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain('bg-test-1');
|
||||
expect(result.stderr).toContain('completed');
|
||||
expect(result.stderr).toContain('/tmp/bg-test-1.output');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Hook Event API Integration Tests ==========
|
||||
// Port 3130 reserved for hooks integration tests
|
||||
|
||||
@@ -700,7 +874,7 @@ describe('Hook Config Generation - Extended', () => {
|
||||
expect(hook.matcher).toBeDefined();
|
||||
expect(hook.hooks).toHaveLength(1);
|
||||
expect(hook.hooks[0].type).toBe('command');
|
||||
expect(hook.hooks[0].timeout).toBe(10000);
|
||||
expect(hook.hooks[0].timeout).toBe(10);
|
||||
expect(hook.hooks[0].command).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// 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');
|
||||
|
||||
function loadOverviewApp(overrides: Record<string, any> = {}) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: {},
|
||||
document: { getElementById: () => null },
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,28 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { existsSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import type { WebServer } from '../src/web/server.js';
|
||||
import { existsSync, rmSync, mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const TEST_PORT = 3099;
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
const ORIGINAL_HOME = process.env.HOME;
|
||||
const TEST_HOME = mkdtempSync(join(tmpdir(), 'codeman-quick-start-'));
|
||||
const CASES_DIR = join(TEST_HOME, 'codeman-cases');
|
||||
let webServerModule: Promise<typeof import('../src/web/server.js')> | undefined;
|
||||
|
||||
process.env.HOME = TEST_HOME;
|
||||
|
||||
async function createTestServer(port: number): Promise<WebServer> {
|
||||
webServerModule ??= import('../src/web/server.js');
|
||||
const { WebServer: TestWebServer } = await webServerModule;
|
||||
return new TestWebServer(port, false, true);
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_HOME === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = ORIGINAL_HOME;
|
||||
rmSync(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Quick Start API', () => {
|
||||
let server: WebServer;
|
||||
@@ -13,7 +30,7 @@ describe('Quick Start API', () => {
|
||||
const createdCases: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
server = await createTestServer(TEST_PORT);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
});
|
||||
@@ -147,7 +164,7 @@ describe('Session Management', () => {
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 1, false, true);
|
||||
server = await createTestServer(TEST_PORT + 1);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
||||
});
|
||||
@@ -206,7 +223,7 @@ describe('Case Management', () => {
|
||||
const createdCases: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 2, false, true);
|
||||
server = await createTestServer(TEST_PORT + 2);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 2}`;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @fileoverview PUT /api/settings must not reset service state on a PARTIAL body.
|
||||
*
|
||||
* The three service toggles (subagent watcher, workflow-run watcher, image
|
||||
* watcher) used to read the RAW REQUEST BODY with `??` defaults, so any key the
|
||||
* caller omitted was treated as "apply the default". A body of just
|
||||
* `{statusLineTelemetry:true}` therefore STARTED the subagent watcher (`?? true`)
|
||||
* and STOPPED the workflow + image watchers (`?? false`), silently undoing the
|
||||
* persisted config. Nothing triggered it in practice only because every shipped
|
||||
* client sends a full settings payload rebuilt from the DOM.
|
||||
*
|
||||
* They now resolve from `merged` (existing settings.json + incoming), so a PUT
|
||||
* reconciles services to the effective stored state. These tests pin that:
|
||||
* omitted keys preserve state, explicit keys still take effect.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
// vi.mock factories are hoisted above module-level consts, so the stubs and the
|
||||
// persisted-settings fixture have to be built inside vi.hoisted().
|
||||
const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
|
||||
/** Watcher stub whose isRunning() reflects its persisted state. */
|
||||
const makeWatcher = (running: boolean) => {
|
||||
let isOn = running;
|
||||
return {
|
||||
isRunning: vi.fn(() => isOn),
|
||||
start: vi.fn(() => {
|
||||
isOn = true;
|
||||
}),
|
||||
stop: vi.fn(() => {
|
||||
isOn = false;
|
||||
}),
|
||||
getStats: vi.fn(() => ({})),
|
||||
watchSession: vi.fn(),
|
||||
getRecentRunSummaries: vi.fn(() => []),
|
||||
// The stubs are module singletons (vi.mock needs them hoisted), so a
|
||||
// start()/stop() in one test would otherwise carry into the next and make
|
||||
// its "not called" assertion pass vacuously — isRunning() already matches
|
||||
// the expected end state, so toggleService short-circuits.
|
||||
__resetRunning: () => {
|
||||
isOn = running;
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF.
|
||||
EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true },
|
||||
subagentWatcher: makeWatcher(false),
|
||||
imageWatcher: makeWatcher(true),
|
||||
workflowRunWatcher: makeWatcher(true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
|
||||
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
|
||||
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
|
||||
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
|
||||
|
||||
describe('PUT /api/settings — partial body must not reset service toggles', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) {
|
||||
w.start.mockClear();
|
||||
w.stop.mockClear();
|
||||
w.__resetRunning(); // running state, not just call records — see makeWatcher
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('leaves all three watchers alone when the body omits their keys', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
// Action-only body: the exact shape that used to flip all three watchers.
|
||||
payload: { statusLineTelemetry: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Persisted OFF and omitted — must NOT be started by the `?? true` default.
|
||||
expect(subagentWatcher.start).not.toHaveBeenCalled();
|
||||
// Persisted ON and omitted — must NOT be stopped by the `?? false` defaults.
|
||||
expect(imageWatcher.stop).not.toHaveBeenCalled();
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still starts a watcher when the body explicitly enables it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { subagentTrackingEnabled: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(subagentWatcher.start).toHaveBeenCalledTimes(1);
|
||||
// Unrelated watchers stay untouched.
|
||||
expect(imageWatcher.stop).not.toHaveBeenCalled();
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still stops a watcher when the body explicitly disables it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { imageWatcherEnabled: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(imageWatcher.stop).toHaveBeenCalledTimes(1);
|
||||
expect(subagentWatcher.start).not.toHaveBeenCalled();
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the workflow watcher running when only one of its two keys is sent', async () => {
|
||||
// Either showUltracodeAgents OR ultracodeFloatingWindows keeps it alive, and
|
||||
// the OR must be evaluated over merged state, not over this partial body.
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { ultracodeFloatingWindows: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// showUltracodeAgents is still true in settings.json, so it stays up.
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -71,6 +71,16 @@ 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', () => {
|
||||
@@ -557,3 +567,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']);
|
||||
});
|
||||
});
|
||||
|
||||
+55
-5
@@ -1,16 +1,36 @@
|
||||
/**
|
||||
* @fileoverview Global test setup for Codeman tests
|
||||
*
|
||||
* SAFETY: TmuxManager has built-in test mode detection
|
||||
* (via process.env.VITEST) that makes ALL shell commands no-ops.
|
||||
* This means tests CANNOT kill, create, or interact with real tmux
|
||||
* sessions regardless of what the test code does.
|
||||
* SAFETY: The suite gets a temporary HOME and explicitly enables runtime test
|
||||
* mode before application modules load. Tests therefore cannot touch the real
|
||||
* Codeman state/cases tree or launch external tmux-backed agent sessions.
|
||||
*
|
||||
* This setup file strips shell-level auth configuration that can leak from a
|
||||
* running Codeman instance, then handles mock/timer cleanup between tests.
|
||||
*/
|
||||
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, afterEach, vi } from 'vitest';
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalVitest = process.env.VITEST;
|
||||
const originalPlaywrightBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
const testHome = mkdtempSync(join(tmpdir(), 'codeman-vitest-'));
|
||||
|
||||
if (originalPlaywrightBrowsersPath === undefined && originalHome) {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH =
|
||||
process.platform === 'darwin'
|
||||
? join(originalHome, 'Library', 'Caches', 'ms-playwright')
|
||||
: process.platform === 'win32'
|
||||
? join(process.env.LOCALAPPDATA || join(originalHome, 'AppData', 'Local'), 'ms-playwright')
|
||||
: join(originalHome, '.cache', 'ms-playwright');
|
||||
}
|
||||
process.env.HOME = testHome;
|
||||
process.env.USERPROFILE = testHome;
|
||||
process.env.VITEST = 'true';
|
||||
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
@@ -23,3 +43,33 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Let in-flight console-log rpc forwards drain before the worker environment
|
||||
// tears down. On loaded CI runners the channel otherwise closes while the last
|
||||
// "onUserConsoleLog" call is still pending, and that single unhandled
|
||||
// EnvironmentTeardownError fails the run after every test has passed
|
||||
// (observed twice on the PR #175/#176 merge commit; never locally).
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
|
||||
if (originalVitest === undefined) delete process.env.VITEST;
|
||||
else process.env.VITEST = originalVitest;
|
||||
|
||||
if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath;
|
||||
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// afterAll never fires for a fully-skipped test file (no tests execute), which
|
||||
// would leak the temp home created above. The exit hook is the backstop; rmSync
|
||||
// with force is a no-op when afterAll already removed it.
|
||||
process.on('exit', () => {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -487,7 +487,10 @@ describe('runtimeUrlShim', () => {
|
||||
* 404s on Codeman's own root while the dashboard's fetch-driven data loads fine.
|
||||
*
|
||||
* Node environment on purpose, like test/markdown-sanitizer.test.ts: a per-file
|
||||
* `@vitest-environment jsdom` externalizes node builtins under vite.
|
||||
* jsdom environment directive would externalize node builtins under vite. The
|
||||
* directive is deliberately not written out here, even in prose: vitest scans
|
||||
* comments for it, and naming it flipped this whole file to the jsdom
|
||||
* environment while this comment claimed the opposite.
|
||||
*/
|
||||
describe('runtimeUrlShim DOM sinks', () => {
|
||||
const body = runtimeUrlShim(PREFIX)
|
||||
|
||||
Reference in New Issue
Block a user