Compare commits

..
Author SHA1 Message Date
Codeman maintainer 1b1057d9e0 chore: version packages 2026-08-04 13:01:28 +02:00
Codeman maintainer 26cbbe0dcb feat(cli): Antigravity run mode
Adds Antigravity as a sixth CLI backend alongside Claude Code, shell, OpenCode,
Codex and Gemini, following the existing pluggable-resolver pattern.

- `utils/antigravity-cli-resolver.ts` resolves the CLI, mirroring the other
  resolvers; `GET /api/antigravity/status` reports availability and path.
- `ANTIGRAVITY_*` joins the `ALLOWED_ENV_PREFIXES` allowlist in schemas.ts, so
  env overrides stay CLI-scoped rather than blanket-forwarded.
- Session, tmux-manager, mux-interface and types carry the new mode; secrets are
  injected via socket-scoped `tmux setenv`, never on the spawn command line, so
  the mode requires tmux with no direct PTY fallback like the other external CLIs.
- Frontend: Run-dropdown entry, agent-type option, `ag` tab badge and toolbar
  colours. `runAntigravity()` routes remote/docker cases through
  `POST /api/quick-start` and skips the local status probe for them.

Tests: test/antigravity-mode.test.ts, plus run-mode-ui and system-routes coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:59:40 +02:00
Codeman maintainer 1113d34ca8 feat(ui): opt-in entrance animations for tabs, terminal pane, agent windows and connection lines
All OFF by default (the `legacy` theme), so an untouched install behaves exactly
as before and every mark/apply hook short-circuits on its first line. Opt in via
App Settings > Appearance > Entrance Animations; per-surface control and a live
preview lab at ?animlab=1.

Surfaces and styles:
- Tabs: slide, pop, crt, unroll, boot, flip. A batch launched together cascades
  by a configurable stagger.
- Terminal pane: crt, boot, wipe, slide, fade.
- Agent windows: fly (the pre-existing tab-to-window flight, still the default),
  crt, materialize, unfold, beam, pop.
- Connection lines: draw, packet, fade.

Three constraints drove the design:

1. Tabs and connection lines are DESTROYED mid-animation on every re-render:
   _fullRenderSessionTabs() replaces the strip's innerHTML and
   _updateConnectionLinesImmediate() does `svg.innerHTML = ''`, both of which run
   constantly while sessions and agents spawn. Each is tracked by id and
   re-applied to the fresh element with a NEGATIVE animation-delay so it resumes
   at the same offset instead of restarting or snapping. Verified on the real
   path: a forced rebuild mid-draw resumed at -0.243s.

2. Terminal-pane styles animate transform/opacity/clip-path ONLY. xterm's
   FitAddon derives rows+cols from getComputedStyle(parent).width/height, the
   untransformed layout box, so transforms are invisible to it; animating
   width/height/padding would have resized the PTY. Verified by forcing
   fitAddon.fit() eight times mid-animation: dimensions held at 178x38.

3. A window entrance that transforms also moves the rect its connection line
   aims at (crt drifts it 109px, pop 81px). `beam` animates opacity/filter only
   (0px drift) so its line can draw toward a stable target; the others refresh
   the lines on animationend.

Also fixes: an agent window spawning hidden (its agent belongs to a background
tab) is display:none, so its animation never runs and animationend never fires,
which left the entrance class and its inline custom property stuck on the window
permanently. Hidden windows now skip the entrance entirely.

Styles persist to their own codeman:*Anim localStorage keys, keeping them
per-device without touching the .strict() SettingsUpdateSchema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:31:52 +02:00
Codeman maintainer 8a31f10b7d chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:09:17 +02:00
Ark0N 2891ae0d6d Merge pull request #178 from Lint111/agent/split-notification-noise
fix(notifications): quiet lifecycle hook noise
2026-08-03 14:07:54 +02:00
Ark0N 17b86b1007 Merge pull request #177 from Lint111/agent/split-transcript-tool-results
fix(transcripts): complete tools from user results
2026-08-03 14:05:38 +02:00
Codeman maintainer 7e357691af chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:41:26 +02:00
Codeman maintainer 80e7249a39 fix(hooks,test): harden background rewake, fix hook timeout units, stabilize CI teardown
Follow-ups from the PR #175/#176 reviews:

- Rewake helper self-terminates on its own 6h deadline and when orphaned,
  instead of relying on Claude Code to reap the poller
- Rewake marker versioned (V2) with a version-agnostic ownership prefix, so
  future script updates replace older handlers instead of duplicating them;
  regression test covers the V1 to V2 swap
- HOOK_TIMEOUT_MS renamed to HOOK_TIMEOUT_SECONDS = 10: the hook timeout
  field is seconds (the CLI multiplies by 1000), so the curl hooks have
  effectively had a ~2.8h timeout since COD-54
- Test echo PTY switches to raw mode: each input byte echoes exactly once
  (tty line discipline doubled every line and buffered until Enter)
- test/setup.ts: drain in-flight console-log rpc forwards before environment
  teardown (fixes the EnvironmentTeardownError that failed CI twice on the
  merge commit with all 3820 tests passing), clean the temp home on process
  exit (fully-skipped files leaked it), fix the Windows Playwright cache
  fallback path
- test/webview-proxy.test.ts: stop naming the vitest environment directive in
  prose; vitest matches it inside comments and silently ran the whole file
  under the jsdom environment while the comment claimed node
- CLAUDE.md: document the temp-HOME and echo-PTY test isolation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:47:52 +02:00
Ark0N e0226f7186 Merge pull request #176 from Lint111/agent/split-hook-lifecycle
fix(hooks): reawaken jobs without replacing user hooks
2026-07-31 08:33:58 +02:00
Ark0N e8681f575f Merge pull request #175 from Lint111/agent/split-quick-start-fixture
test: isolate runtime state and PTY integration
2026-07-31 07:22:30 +02:00
Codeman maintainer 64be4e3029 ci(release): pin the Latest badge to the Codeman release
The workspace publishes two packages, 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. Set make_latest in the rename PATCH, which runs after every
package release already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:20:32 +02:00
Codeman maintainer cb7d0ba565 chore: version packages
PUT /api/settings service toggles now resolve from `merged` (persisted +
incoming) instead of the raw request body, so a partial PUT no longer
starts the subagent watcher and stops the workflow + image watchers by
treating every omitted key as "apply the default". Pinned by a 4-case
regression test verified to fail against the old handler.

Also trims the links line from the Codeman callout in the
xterm-zerolag-input README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:11:25 +02:00
Codeman maintainer 22cb563f1e chore: version packages
Plan-usage chip defaults ON on desktop (handhelds stay OFF), resolved
through a single planUsageChipEnabled() helper so the checkbox, the chip
and the create-time statusLineTelemetry flag cannot disagree. Correct the
stale "Cron button defaults ON" comment (it is OFF in code, template and
CSS) and the styles.css comment claiming the server strips the chip's
hidden class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:27:45 +02:00
Codeman maintainer f0e13f9fc3 docs(zerolag): Codeman promo up top, simpler 30-second graphic
Replace the misaligned 8-line keystroke-flow diagram (its branch sat two
columns off the junction it attached to) with a two-lane contrast that
makes the same point in two lines: stock xterm.js waiting 300ms vs the
overlay painting immediately. The mechanism detail it was annotating
moved into the following paragraph.

Add a Codeman callout between the badges and the demo GIF, with links to
getcodeman.com, the install one-liner and the repo, and rewrite the
bottom Origin section so it argues credibility instead of repeating the
promo.

Not released: the npm page updates only on publish, so the next COM
needs an "xterm-zerolag-input": patch changeset for this to ship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:36:08 +02:00
lior bba3d80971 test: isolate runtime state and PTY integration 2026-07-29 09:19:46 +03:00
lior 67eb5b43eb fix(notifications): quiet lifecycle hook noise 2026-07-28 23:20:01 +03:00
lior 4a4720cb62 fix(transcripts): complete tools from user results 2026-07-28 23:18:10 +03:00
lior 3c903b36ca fix(hooks): reawaken jobs without replacing user hooks 2026-07-28 23:16:37 +03:00
lior 7c07284b95 test: isolate quick-start case fixtures 2026-07-28 23:12:27 +03:00
50 changed files with 3436 additions and 294 deletions
+10 -2
View File
@@ -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
+56
View File
@@ -1,5 +1,61 @@
# aicodeman
## 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
+12 -10
View File
@@ -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.7 (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,10 +122,10 @@ 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`
@@ -183,7 +183,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 +193,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 +229,9 @@ 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) → `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`.
**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 +274,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 +294,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 +322,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).
+1 -1
View File
@@ -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
+88 -26
View File
@@ -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)_
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.9.2",
"version": "1.9.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.9.2",
"version": "1.9.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -12333,7 +12333,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.1.5",
"version": "0.1.7",
"license": "MIT",
"devDependencies": {
"jsdom": "^24.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.9.2",
"version": "1.9.7",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+21
View File
@@ -1,5 +1,26 @@
# xterm-zerolag-input
## 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
+18 -14
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.1.5",
"version": "0.1.7",
"description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
+7 -2
View File
@@ -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;
+8
View File
@@ -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',
+1
View File
@@ -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
View File
@@ -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');
});
}
+3
View File
@@ -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). */
+1
View File
@@ -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;
}
+43 -10
View File
@@ -49,6 +49,7 @@ import {
type CodexConfig,
type EffortLevel,
type GeminiConfig,
type AntigravityConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -140,7 +141,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 +152,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Codex';
case 'gemini':
return 'Gemini';
case 'antigravity':
return 'Antigravity';
case 'shell':
return 'Shell';
case 'claude':
@@ -180,6 +183,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 +406,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 +494,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 +569,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 +1123,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,23 +1268,30 @@ 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), {
this.ptyProcess = pty.spawn(attachCommand, attachArgs, {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
// 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'),
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
});
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
@@ -1329,6 +1356,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 +1529,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,6 +1611,10 @@ 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
@@ -2683,7 +2716,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
View File
@@ -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
View File
@@ -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
View File
@@ -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) */
+65
View File
@@ -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;
}
+1
View File
@@ -30,3 +30,4 @@ export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-c
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';
+25 -3
View File
@@ -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,9 @@ 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?.();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
@@ -3532,7 +3545,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 +3582,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 +4116,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 +4630,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');
+754
View File
@@ -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">&times;</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">&#129302;</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);
}
},
});
+1
View File
@@ -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': '新建案例',
+20 -1
View File
@@ -464,6 +464,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 +736,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 +1280,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">
@@ -1848,7 +1866,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 +2644,7 @@
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.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>
+20
View File
@@ -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%;
@@ -2286,6 +2300,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;
}
+97 -42
View File
@@ -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;
+1 -1
View File
@@ -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',
+62 -6
View File
@@ -370,7 +370,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 +394,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 +506,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 +719,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 +1079,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 +1142,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 +1172,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 +2592,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';
},
});
+38 -13
View File
@@ -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,11 @@ 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.
// 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 +414,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;
@@ -1587,7 +1593,7 @@ Object.assign(CodemanApp.prototype, {
audio: document.getElementById('eventSubagentAudio').checked,
},
},
_version: 4,
_version: 5,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
@@ -1798,6 +1804,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,
@@ -1889,6 +1898,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 +1988,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);
@@ -2247,9 +2270,10 @@ Object.assign(CodemanApp.prototype, {
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
]);
// 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 +2299,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
View File
@@ -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;
+27 -3
View File
@@ -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();
}
+4
View File
@@ -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);
+87 -37
View File
@@ -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,
+22 -5
View File
@@ -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
View File
@@ -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']),
+1
View File
@@ -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,
+123
View File
@@ -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');
});
});
+51 -10
View File
@@ -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
View File
@@ -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();
}
});
+150
View File
@@ -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');
});
});
+24 -7
View File
@@ -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();
});
});
+34
View File
@@ -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', () => {
+63
View File
@@ -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
View File
@@ -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 });
});
+68 -31
View File
@@ -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');
});
});
+4 -1
View File
@@ -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)