From c5b59633d8a67985fe17dc10eba033659d65301e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 13 Aug 2026 13:54:47 +0200 Subject: [PATCH 1/4] feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206) SessionMode gains 'pi', a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron agentType, Docker and remote-SSH command defaults, and clone-repo Brain option. Pi is a different shape of CLI from the other four, and three decisions follow from that: - It has NO permission prompts and no sandbox, so there is no --dangerously-skip-permissions analog and none was invented. The privilege-shaped knob is the tri-state approveProjectTrust, which makes pi load and EXECUTE repo-local .pi/extensions TypeScript and install missing project packages. clampExternalCliBypassForOwner() therefore puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets --no-approve even when no config was sent, because pi's own default is a prompt the session user could answer themselves. That helper had zero test coverage; it now has coverage for all four CLIs. - Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Auth goes through pi's /login or the server's own environment. --api-key is deliberately never wired: it would put a provider secret on the spawn command line. - pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the main screen with terminal-owned scrollback, and its 0.84.0 fullscreen mode is runtime-switchable via /settings; that flip was measured to put the pane into the alt screen, which the strip would have corrupted. pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name a stray binary can shadow; GET /api/pi/status surfaces path and version so a misresolution is diagnosable rather than presenting as a broken mode. Docker installs pi in its own --ignore-scripts step so that flag cannot affect the other four CLIs, and seeds its credentials per-file rather than whole-dir (~/.pi/agent also holds sessions, extensions and package trees). Verified end to end against pi 0.84.1 on an isolated instance: resolver search-dir fallback, flag construction, piConfig persistence across a full server restart, the trust prompt and its --no-approve suppression, the rose Run button on the default daylight-blue skin (the nested skin block eats per-mode gradients unless the rule lives inside it), and the buffer local-echo policy, which pi tolerates where codex did not. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/ba4bc996.md | 16 + CLAUDE.md | 10 +- README.md | 18 +- README.zh-CN.md | 14 +- docker/agent.Dockerfile | 11 +- docs/architecture-invariants.md | 6 +- docs/cron-discovery.md | 4 +- docs/cron-guide.md | 2 +- docs/docker-cases.md | 8 +- docs/pi-integration-plan.md | 681 ++++++++++++++++++ docs/pi-integration.md | 235 ++++++ docs/remote-sessions.md | 4 +- docs/security-architecture.md | 2 +- install.sh | 57 +- package.json | 1 + src/config/dependency-registry.ts | 8 + src/docker-hosts.ts | 14 + src/mux-interface.ts | 3 + src/remote-hosts.ts | 1 + src/session.ts | 36 +- src/tmux-manager.ts | 82 ++- src/types/session.ts | 42 +- src/utils/index.ts | 1 + src/utils/pi-cli-resolver.ts | 134 ++++ src/web/public/app.js | 14 +- src/web/public/i18n.js | 1 + src/web/public/index.html | 11 +- src/web/public/mobile-overview.js | 1 + src/web/public/mobile.css | 25 + src/web/public/panels-ui.js | 2 +- src/web/public/session-ui.js | 76 +- src/web/public/settings-ui.js | 1 + src/web/public/styles.css | 57 +- src/web/public/terminal-ui.js | 2 +- src/web/routes/session-routes.ts | 90 ++- src/web/routes/system-routes.ts | 17 +- src/web/schemas.ts | 44 +- src/web/server.ts | 4 + test/local-echo-codex-gating.test.ts | 25 +- test/mobile-overview.test.ts | 2 +- test/pi-mode.test.ts | 191 +++++ test/render-index-html.test.ts | 9 + test/routes/external-cli-bypass-clamp.test.ts | 132 ++++ test/routes/system-routes.test.ts | 42 ++ test/run-mode-ui.test.ts | 108 ++- 45 files changed, 2143 insertions(+), 101 deletions(-) create mode 100644 .changeset/ba4bc996.md create mode 100644 docs/pi-integration-plan.md create mode 100644 docs/pi-integration.md create mode 100644 src/utils/pi-cli-resolver.ts create mode 100644 test/pi-mode.test.ts create mode 100644 test/routes/external-cli-bypass-clamp.test.ts diff --git a/.changeset/ba4bc996.md b/.changeset/ba4bc996.md new file mode 100644 index 00000000..c0459dfe --- /dev/null +++ b/.changeset/ba4bc996.md @@ -0,0 +1,16 @@ +--- +"aicodeman": minor +--- + +Add Pi (pi.dev) as a sixth CLI run mode (#206). + +`SessionMode` gains `'pi'`, a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron `agentType`, Docker and remote-SSH command defaults, and clone-repo Brain option. + +- **New resolver** `src/utils/pi-cli-resolver.ts`. Unlike the sibling resolvers it sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name that a stray binary on `$PATH` can shadow; the rejected path is logged. `GET /api/pi/status` returns `{ available, path, version }` so a misresolution is diagnosable. +- **`PiConfig`** maps to `--model` (accepts `provider/id` and a `:thinking` suffix), `--provider`, `--thinking`, `--session`/`-c`, and the tri-state `--approve` / `--no-approve`. Every value is regex-allowlisted and dropped on failure. `--api-key` is deliberately never wired: it would put a provider secret on the spawn command line. +- **No bypass flag.** Pi has no permission prompts and no sandbox, so there is no `--dangerously-skip-permissions` analog. Its privilege-shaped knob is `approveProjectTrust`, which makes pi load and execute repo-local `.pi/extensions` TypeScript and install missing project packages. `clampExternalCliBypassForOwner()` therefore puts pi in the **materialize** branch: a non-granted multi-user owner gets `--no-approve` even when no config was sent, because pi's own default is an interactive prompt the session user could answer themselves. That helper had no test coverage at all; it now does, for all four CLIs. +- **Env allowlist gains only the `PI_*` prefix.** Pi's ~34 provider key vars share no prefix and `ALLOWED_ENV_PREFIXES` is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Users authenticate via pi's `/login` or the server process's own environment. +- **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback, and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings` — verified to switch the pane into the alt screen, which the strip would have corrupted. +- **Docker**: pi installs in its own `--ignore-scripts` step so that flag cannot affect the other four CLIs, and its credentials are seeded per-file (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json`) rather than whole-dir, since `~/.pi/agent` also holds sessions, extensions and installed package trees. +- **Local echo**: pi lands on the buffer overlay. Verified that codex's per-keystroke starvation does not reproduce — pi's slash picker re-filters on the whole composer content, so a one-shot flush behaves identically to per-keystroke typing. +- Installer detection, docs (`docs/pi-integration.md`), READMEs, and the architecture invariants are updated. Tests: `test/pi-mode.test.ts` and `test/routes/external-cli-bypass-clamp.test.ts`, plus extensions to the run-mode, mobile-overview, render-index-html, system-routes and local-echo suites. diff --git a/CLAUDE.md b/CLAUDE.md index d8e20a00..d339d5ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the b 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), 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'`). +**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), Antigravity (`agy`, Google) and Pi (pi.dev) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'`). **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. @@ -124,10 +124,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_*` / `ANTIGRAVITY_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects /projects`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir) +- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects /projects`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir) - **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort ` 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 `/.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_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). 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_*` vs `PI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-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** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(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 bundles.** 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` @@ -169,7 +169,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Config**: `src/config/` — 20 files, no barrel (`index.ts`) exists; import from the specific file. -**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority). +**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver` (CLI path resolution; ⚠ `pi-cli-resolver` additionally version-probes the binary, since `pi` is a generic name), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority). ### Data Flow @@ -200,7 +200,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, 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. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi)**: `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 five **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. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi) **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-` 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) diff --git a/README.md b/README.md index f3474e78..e6d93837 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Mission control for AI coding agents

- Claude Code • OpenCode • Codex • Antigravity • Gemini • Terminal - One Dashboard • Any Device + Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Terminal - One Dashboard • Any Device

@@ -27,7 +27,7 @@ Codeman — parallel subagent visualization

-**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, or Gemini CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. +**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. Get started in one line (macOS & Linux, Windows via WSL): @@ -42,7 +42,7 @@ codeman web The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). -- **One dashboard, five CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, or Gemini](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) +- **One dashboard, six CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts @@ -68,7 +68,7 @@ This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, a - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist. - **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the five is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: +You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi](https://pi.dev) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the six is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: ```bash codeman web @@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), or [Gemini CLI](https://github.com/google-gemini/gemini-cli)). After installing, `http://localhost:3000` is accessible from your Windows browser. +Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi](https://pi.dev)). After installing, `http://localhost:3000` is accessible from your Windows browser. @@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in | Field | What it does | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). | -| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, or `Terminal` (plain shell). | +| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, or `Terminal` (plain shell). | | **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. | | **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | @@ -429,7 +429,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) +- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, or **Pi** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) and [`docs/pi-integration.md`](docs/pi-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too @@ -451,7 +451,7 @@ Run a case inside its own hardened Docker container instead of directly on your - **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap. - **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others. - **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away. -- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets. +- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets. - **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. - **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript. @@ -996,7 +996,7 @@ flowchart TB end subgraph External["External"] - CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi"] BG["Background Agents
(Task tool)"] end end diff --git a/README.zh-CN.md b/README.zh-CN.md index d4298a6b..a83b5291 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@

AI 编程智能体的任务控制中心

- Claude Code • OpenCode • Codex • Antigravity • Gemini • 终端 —— 统一仪表盘 • 任意设备 + Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • 终端 —— 统一仪表盘 • 任意设备

@@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash - **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。 - **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。 -你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这五个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: +你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli) 或 [Pi](https://pi.dev)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这六个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: ```bash codeman web @@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 +Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli) 或 [Pi](https://pi.dev))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 @@ -221,7 +221,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_ | 字段 | 作用 | | ---------------------- | ------------------------------------------------------------------------------------------- | | **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 | -| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini` 或 `Terminal`(普通 shell)。 | +| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi` 或 `Terminal`(普通 shell)。 | | **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 | | **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 | @@ -394,7 +394,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端 ## 更多特性 - **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新) -- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity** 或 **Gemini**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md) +- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini** 或 **Pi**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`PI_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md) 与 [`docs/pi-integration.md`](docs/pi-integration.md) - **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md) - **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置 @@ -416,7 +416,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端 - **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。 - **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。 - **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。 -- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。 +- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。 - **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。 - **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。 @@ -906,7 +906,7 @@ flowchart TB end subgraph External["外部"] - CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi"] BG["后台智能体
(Task 工具)"] end end diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 431e0a10..d0c1accc 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -44,6 +44,13 @@ RUN curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr && chmod 755 /usr/local/bin/agy \ && agy --version +# Pi (pi.dev). Upstream documents --ignore-scripts (pi needs no lifecycle scripts); +# kept out of the shared npm block above so the flag cannot silently change how the +# other four CLIs install. +RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \ + && npm cache clean --force \ + && pi --version + # `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is # auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at # runtime Codeman overrides with `--user :0` on Linux, so the baked uid @@ -61,9 +68,11 @@ ENV HOME=/home/agent # transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from # the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir; # Antigravity nests its state inside `.gemini/antigravity-cli`, so it rides that seed.) +# `.pi/agent` IS pre-created: pi is seeded per-FILE (auth/settings/trust/models), and a +# per-file seed copy, unlike a whole-dir one, does not create its parent directory. RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ - /home/agent/.claude/projects /home/agent/.codex/sessions \ + /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent \ && chgrp -R 0 /home/agent \ && chmod -R g=u /home/agent diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 46e84013..7dc17295 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -18,9 +18,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ## Session launch modes -### External CLI modes (OpenCode, Codex, Gemini, Antigravity) +### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All four modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All five modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). + +**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session ` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). **Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incl. byte-identity + 300ms-RTT). diff --git a/docs/cron-discovery.md b/docs/cron-discovery.md index ea8db1ac..ba14980f 100644 --- a/docs/cron-discovery.md +++ b/docs/cron-discovery.md @@ -44,9 +44,9 @@ records), kept distinct from the existing `ScheduledRun`. ## 2. Where agent/session types are defined -- `type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity'` +- `type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'` (`src/types/session.ts:43-44`). `shell` covers the brief's "Terminal/custom". -- CLI availability resolvers in `src/utils/{claude,codex,gemini,antigravity,opencode}-cli-resolver.ts`. +- CLI availability resolvers in `src/utils/{claude,codex,gemini,antigravity,opencode,pi}-cli-resolver.ts`. - **Integration point:** the job's `agentType` reuses `SessionMode` verbatim. ## 3. Where input is sent into a session diff --git a/docs/cron-guide.md b/docs/cron-guide.md index a4bc301c..4c4bc448 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -91,7 +91,7 @@ These map 1:1 to `CronJobSchema` (`src/web/schemas.ts`) and the `CronJob` type | Field | Required | Values / limits | Notes | | -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | ✅ | 1–200 chars | Display name; also used as the created session's name. | -| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. | +| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` \| `pi` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. ⚠️ A `pi` job's readiness poll looks for `❯`/a token count, neither of which pi prints, so it burns the poll budget and then sends the prompt anyway (slower start, still works). | | `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. | | `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. | | `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. | diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 7f74f13b..f57ae28f 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -2,7 +2,7 @@ Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**. -Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` all work inside the container. +Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` all work inside the container. ## One-time setup: build the base image @@ -25,10 +25,12 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif ```bash docker run --rm codeman/agent:base bash -lc \ - 'for c in claude codex gemini opencode agy; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' + 'for c in claude codex gemini opencode agy pi; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' ``` -Antigravity (`agy`) is the one CLI not installed from npm (Google ships a standalone binary), so it has its own Dockerfile step and adds roughly 190MB; a full image lands near 1.6GB. +Antigravity (`agy`) is the one CLI not installed from npm (Google ships a standalone binary), so it has its own Dockerfile step and adds roughly 190MB; a full image lands near 1.6GB. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other four npm CLIs install. + +Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). ## Quickest path: one-click "Run in Docker" diff --git a/docs/pi-integration-plan.md b/docs/pi-integration-plan.md new file mode 100644 index 00000000..6c4d165e --- /dev/null +++ b/docs/pi-integration-plan.md @@ -0,0 +1,681 @@ +# Pi (pi.dev) Run Mode: Implementation Plan + +Tracking issue: [#206 "Plans to support pi.dev?"](https://github.com/Ark0N/Codeman/issues/206) + +Status: **IMPLEMENTED 2026-08-13** (see `docs/pi-integration.md` for the user-facing +guide). Everything below is the design record; the open questions were resolved +empirically against pi 0.84.1 and the answers are recorded inline as **RESULT** +notes. Originally reworked 2026-08-06; **rechecked 2026-08-13 against master @ +`f39beb3` (v1.17.0)**, and every line anchor below was re-verified at that commit (the 1.11.2-era +anchors drifted heavily: six releases landed in between, including the settings-surface overhaul and +the codex predictive-echo work, both of which added new pi touchpoints, §2.10 and the Brain picker in +Phase 3). Upstream facts verified against `@earendil-works/pi-coding-agent` **v0.84.1** (npm latest, +published 2026-08-07) and the [`earendil-works/pi`](https://github.com/earendil-works/pi) repo (cite +that name: upstream docs still contain stale `pi-mono` links from a repo rename). Line numbers are +anchors for orientation, not contracts; they drift. + +--- + +## 1. What Pi is + +[Pi](https://pi.dev) (MIT) is a minimal, extensible coding-agent harness. Facts below are verified +against the upstream docs in `packages/coding-agent/docs/`. + +| Property | Value | +| ---------------- | -------------------------------------------------------------------------------------------------- | +| Binary | `pi` (`bin: { pi: 'dist/cli.js' }`) | +| npm package | `@earendil-works/pi-coding-agent`, latest **0.84.1** (2026-08-07; 0.84.0 was 2026-08-06); `legacy-node20` dist-tag at 0.74.2 | +| Install | `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, or `curl -fsSL https://pi.dev/install.sh \| sh` (the curl installer also goes through global npm, so both uninstall via npm) | +| Config dir | `~/.pi/agent` (override: `PI_CODING_AGENT_DIR`). Holds `auth.json`, `trust.json`, `settings.json`, `models.json` (user-defined providers), `models-store.json` (cached catalogs), `keybindings.json`, `extensions/`, `skills/`, `prompts/`, `themes/`, `AGENTS.md`, `SYSTEM.md`, and the package trees `npm/` + `git/` | +| Sessions | `~/.pi/agent/sessions/----/_.jsonl`, tree-structured (`id`/`parentId`), format v3. Overrides: `PI_CODING_AGENT_SESSION_DIR`, `--session-dir` | +| Credentials | `~/.pi/agent/auth.json` (OAuth subscriptions + API keys, auto-refresh), plus ~34 provider env vars with **no common prefix**. 0.84.1 adds `pi auth check` (auth preflight with optional credential output) | +| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode | +| Providers | 15+ (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, xAI, OpenRouter, Copilot, Baseten since 0.84.0, ...). OAuth subscription login via `/login` for six: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter, Radius | +| Permission model | **No permission prompts at all.** No built-in sandbox, no MCP (none planned), no sub-agents, no plan mode, no to-dos, no background bash. Tools run with the user's own permissions | +| Trust model | "Project trust" gates **loading** of project-local `.pi/` config/extensions/skills and **installing missing project packages**, not tool execution. Triggered only when the cwd (or an ancestor) contains `.pi/settings.json`, `.pi/extensions\|skills\|prompts\|themes`, `.pi/SYSTEM.md`/`.pi/APPEND_SYSTEM.md`, or `.agents/skills`; a bare `.pi/` directory does NOT prompt. Global `defaultProjectTrust`: `ask` (default) / `always` / `never` | + +Three consequences shape the whole integration: + +1. **There is no `--dangerously-skip-permissions` analog and none is needed.** Pi never prompts for + tool approval. The Claude/Codex/Gemini/Antigravity pattern of "send the bypass flag so the session + is not stuck on a modal" does not apply. Codeman must not invent a flag here. +2. **The one privileged knob is `--approve` / `-a`** (trust project-local files for this run), which + makes pi load and execute project `.pi/extensions` TypeScript **and run an npm install of missing + project packages**. That is the field the multi-user clamp has to cover. Its explicit inverse + `-na` / `--no-approve` exists, which lets the clamp force-deny rather than merely omit (§3, §5.2). +3. **Provider keys cannot ride the env allowlist.** Pi's provider key vars (`ANTHROPIC_API_KEY`, + `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `HF_TOKEN`, `BASETEN_API_KEY`, ...) share no prefix, so + there is no way to admit them through `ALLOWED_ENV_PREFIXES` without widening the list for every + mode (§2.4). + +--- + +## 2. Design decisions + +### 2.1 Mode identity + +`SessionMode` gains `'pi'`. Not a location overlay (unlike Docker/remote-SSH cases), not a web tab: +a real sixth CLI backend with its own PTY, tmux session and respawn behaviour, exactly like +`antigravity`. Append `pi` after `antigravity` in every enum/list to keep ordering consistent. + +| Surface | Value | +| ---------------- | --------------------------------------------------------------------- | +| `SessionMode` | `'pi'` | +| Display label | `Pi` | +| Tab badge | `pi` (two-letter lowercase, like `sh`/`oc`/`cx`/`gm`/`ag`) | +| Run button label | `Run PI` (short-label ternary in `_applyRunMode`, pattern `Run AG`) | +| Kill-menu label | `Kill Tmux & Pi` | +| Identity color | **`#f472b6` (rose-400)**. Verified free: live computed values on the default skin are claude `#38b6f0`, opencode `#44b993`, codex `#2b8fd9`, gemini `#8ab4f8`, antigravity `#22d3ee`, shell `#98a2b1`, web `#38bdf8`; purple is codex's base hex and amber reads as the shell tab badge, so pink/rose (or orange `#fb923c`) are the only genuinely free hues. No `pi` CSS identifier collides anywhere (`mode-pi`, `.tab-mode.pi`, `.run-mode-dot.pi` all grep clean, re-checked at f39beb3) | +| Env prefix | `PI_` | +| Dependency id | `pi` | +| Status endpoint | `GET /api/pi/status` | + +### 2.2 `isExternalCliMode()` yes, `isAltScreenStripMode()` no + +Pi joins `isExternalCliMode()` (`session.ts:164-167`): its own TUI, its own output format, so the +Ralph tracker, `BashToolParser`, token/CLI-info scraping and the `❯` readiness probe all stay off +(gates at `session.ts:1100`, `:1701`, `:2000`, `:2103`), and readiness falls back to the output +stabilization used by the other external CLIs. + +Pi stays **out** of `isAltScreenStripMode()` (`session.ts:197-199`, currently codex/claude/gemini; +antigravity and opencode are deliberately excluded). Pi's default TUI renders into the main screen +with terminal-owned scrollback, so there is nothing to strip. The fullscreen mode **shipped in +0.84.0 and is runtime-switchable via `/settings`**, so Codeman cannot assume a pi session stays +main-screen for its lifetime; staying out of the strip list is exactly what makes that safe (the alt +screen is load-bearing when the user flips to fullscreen, as it is for `opencode`). Putting pi IN +the strip list would corrupt fullscreen sessions. Three mirrors must stay consistent (all unchanged +for pi, i.e. pi appears in none of them): the replay-side strip in `session-routes.ts:2275`, the +live-stream twin in `session.ts`, and the frontend `_sessionUsesServerMouseStrip()` in +`terminal-ui.js` (usages `:3432`, `:3697`). + +### 2.3 tmux required, no direct-PTY fallback, no per-mode configurator + +Same rule as the other external CLIs: `pi` mode throws if tmux is unavailable. Add a fourth block to +the guard chain at `session.ts:1751-1768` (antigravity's is `:1765-1768`). + +**No `_configurePi()` is needed.** Opencode/codex/gemini each have a tmux-`setenv` configurator +(`tmux-manager.ts:1709-1727`), but antigravity has none: it relies entirely on the generic +`applyEnvOverrides()` (`tmux-manager.ts:1643`, `VALID_KEY = /^[A-Z_][A-Z0-9_]*$/`), which runs for +every mode in both create (`:1880`) and respawn (`:2107`) and injects via socket-scoped +`tmux setenv`, never the spawn command line. Pi follows the antigravity precedent: `PI_*` overrides +flow through `applyEnvOverrides()` and nothing else. + +Pi joins the truecolor branches: `buildEnvExports()` (`tmux-manager.ts:1604-1609`, +`export COLORTERM=truecolor` + `unset NO_COLOR` for codex/gemini/antigravity) and the attach-env +condition at `session.ts:1400-1402` (`buildMuxAttachEnv(...)`, whose comment says it must mirror +`buildEnvExports`). Add `|| mode === 'pi'` to both, or the tmux session and the attach client +disagree about color depth. + +### 2.4 Env prefix: `PI_` only + +Add `'PI_'` to `ALLOWED_ENV_PREFIXES` (`schemas.ts:125`) and to the prose error message at `:163` +(two edits: the message hardcodes the list, and since 1.12+ it also names the exact-key allowlist, +currently `...ANTIGRAVITY_* keys and CLAUDE_CONFIG_DIR are allowed.`; there is now a separate +`ALLOWED_ENV_KEYS` exact-key set alongside the prefix list, which pi does not need to touch). That +covers every documented variable pi reads: `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, +`PI_PACKAGE_DIR`, `PI_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_TELEMETRY`, `PI_CACHE_RETENTION`, +`PI_SHARE_VIEWER_URL`, `PI_HARDWARE_CURSOR`, `PI_EXPERIMENTAL` (whose meaning 0.84.0 extended to +strict JSON-schema tool sampling). (Pi also *sets* `PI_CODING_AGENT=true` and `AI_AGENT=pi` in child +processes; those are output markers, not inputs, and need nothing from us.) + +**Deliberately not added:** `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, +`GROQ_API_KEY`, `MISTRAL_API_KEY` and the other ~28 provider keys. `ALLOWED_ENV_PREFIXES` is a +single global list applied by one Zod refine with no mode context (`safeEnvOverridesSchema`, +`schemas.ts:153-165`), so allowlisting bare provider keys for pi would widen the allowlist for +**every** mode at once, violating the multi-CLI prefix discipline in CLAUDE.md. Users authenticate +pi through `/login` (stored in `~/.pi/agent/auth.json`, auto-refreshed) or by exporting the key in +the Codeman server process's own environment. + +Making the allowlist mode-aware is the clean fix, listed as a follow-up in §9. Do not smuggle it +into this change. + +### 2.5 Docker credential policy: seed files, not the whole dir + +`CRED_STORES` (`docker-hosts.ts:597-605`; file unchanged since the 2026-08-06 verification) gets a +`.pi/agent` entry. Nested `rel` paths already work (`.config/gcloud` maps to seed name +`.config-gcloud` via the `replace(/\//g, '-')` at `:620`). Unlike antigravity, which needed **no** +entry (`agy` nests all state under `~/.gemini/antigravity-cli/`, already covered by the `.gemini` +policy, per the comment at `:599-602`), pi has its own top-level dir and needs its own entry. Use +`seedFiles`, **not** `seedWhole`: + +```ts +{ rel: '.pi/agent', seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'] }, +``` + +Rationale: `~/.pi/agent` also contains `sessions/`, `extensions/`, `skills/` and the installed +package trees (`npm/`, `git/`), which on an active host is easily gigabytes; `seedWhole` would +`cp -a` all of it into every container start. The five seeded files are what pi needs to +authenticate and behave consistently: `models.json` is in the list because it holds user-defined +custom providers, and omitting it would silently strip those inside containers. Seeding (RO mount +then copy) also means the in-container pi never writes refreshed OAuth tokens back to the host, +which is the whole point of the seeding policy, and bind mounts stay excluded from `docker commit` +so exports remain secret-free. + +Trade-off to accept and document: in-container pi sessions are not visible host-side, so `pi -c` +inside a Docker case only sees that container's own history. Codex shares `sessions/` RW precisely +because Codeman reads it host-side for the response viewer; there is no such reader for pi yet +(the response-viewer follow-up in §9 would justify flipping this). + +### 2.6 The `pi` binary name is generic + +Unlike `agy`/`codex`/`gemini`, `pi` is a short, common name (Raspberry Pi tooling, personal scripts, +`$PATH` accidents). The resolver must not blindly trust a hit. None of the existing external-CLI +resolvers execute their binary (only `claude-cli-resolver.ts` does, via the cached +`getClaudeCliVersion()`, skipped under vitest), so the sanity check is new ground: model it on +`getClaudeCliVersion()`. Run `pi --version` once via `execFileSync`, cache the result module-level, +skip under `VITEST`, and require output matching `/^\d+\.\d+\.\d+/`; on mismatch treat the binary as +unavailable and log the rejected path. Surface `{ available, path, version }` from +`GET /api/pi/status` so a misresolution is diagnosable from the UI (additive relative to the sibling +endpoints' `{ available, path }`). The `dependency-registry` entry carries `versionArg: '--version'` +for `codeman doctor`. + +### 2.7 tmux extended keys (a real pi-specific footgun) + +Pi documents (`docs/tmux.md`, verified verbatim) that without + +```tmux +set -g extended-keys on +set -g extended-keys-format csi-u +``` + +tmux collapses `Shift+Enter` and `Ctrl+Enter` into a plain `\r` (and `Alt+Enter` into `\x1b\r`), and +pi's editor uses those for newline vs submit. `extended-keys-format` requires tmux 3.5+; tmux +3.2-3.4 works with `extended-keys on` alone (pi then falls back to xterm `modifyOtherKeys`). +Codeman's own browser input path sends `\r` for submit, so basic use works unconfigured, but +newline-in-editor is degraded both for a user typing in an attached terminal (`sc`) and potentially +for the browser Shift+Enter path. + +Upstream recommends `~/.tmux.conf` and notes the setting may need a full `tmux kill-server` restart +to take effect. **Codeman must NEVER run `kill-server` on its socket** (it would kill every live +session, including `w1`/`w2`/`w3`). Action: attempt to set both options **server-scoped on +Codeman's own socket only** (`tmux -L codeman set -s ...`, never `-g` on the user's default socket) +at the point the tmux server is first started, verify with `tmux -L codeman show-options -s` and an +empirical Shift+Enter test which scope actually takes for the installed tmux version, and fall back +to a documented manual step in `docs/pi-integration.md` (a `~/.tmux.conf` snippet plus the +kill-server caveat) if it cannot be applied safely to an already-running server. Upstream does not +discuss socket- or server-scoped configuration at all, so this verification is original work, not a +doc lookup. + +**RESULT (measured, tmux 3.4 + pi 0.84.1):** `tmux -L set -s extended-keys on` takes effect +on an **already-running** server with **no `kill-server`** — pi's own startup warning +(`Warning: tmux extended-keys is off…`, a convenient in-band probe) disappears for the next session +started afterwards. `extended-keys-format` does **not exist on tmux 3.4** and errors with +`invalid option: extended-keys-format`, so the two options must be issued independently rather than +chained. Decision: Codeman does **not** set this itself — it is a server-wide tmux option affecting +every session of every backend, so silently changing key encoding is not Codeman's call. It is +documented as a user step in `docs/pi-integration.md` instead, carrying the measured facts. + +### 2.8 The completeness trap: which mode tables fail loud vs silent + +Adding `'pi'` to the `SessionMode` union makes some omissions compile errors and leaves others +silent. The plan calls this out so review can focus on the silent ones. + +**Loud (typecheck fails until edited):** `getModeLabel()` (`session.ts:168-183`, exhaustive switch +with no default), `defaultDockerCommandForMode` and `defaultRemoteCommandForMode` (both typed +`Record<...CommandMode, string>`), **but only after** `RemoteCommandMode` (`types/session.ts:48-51`) +and `DockerCommandMode` (`:157-161`) are widened: both are `Extract` with every +member spelled out, so forgetting the `Extract` lists keeps `tsc` green while docker/remote pi cases +silently fall back to `exec bash -l` via the `|| commands.shell` on the lookup. Edit union + both +`Extract` lists + both `Record` literals together. + +**Silent (compiles clean, mode just doesn't work):** + +- `appendResumeFlag()` (`tmux-manager.ts:1030-1042`) has a `default:` arm; a missing `case 'pi'` + silently drops docker resume. +- `buildSpawnCommand()` (`:770-825`) and `buildPathExport()` (`:1680-1707`) are if-chains with + fallthrough returns; a missing branch spawns pi as a login shell / with no PATH augmentation. +- `isExternalCliMode()` / `isAltScreenStripMode()` are boolean chains. +- The `runMode` accessor's **setter whitelist** (`session-ui.js:2949-2960`) coerces any unknown mode + to `'claude'`. Omitting `pi` there makes the mode **unselectable while every other edit appears to + work**: this is the single most deceptive omission in the frontend. +- `window.__codemanCliAvailable` (injected by `renderIndexHtml`, `server.ts:1375-1407`): the client + treats a **missing key as available** (`isCliAvailable` in settings-ui.js), so forgetting the + injection un-gates pi on boxes without the CLI instead of hiding it. + +### 2.9 The Daylight skin cascade eats per-mode run-button colors + +A finding that changes the CSS work (verified empirically with computed styles on the live +instance, re-confirmed at f39beb3): `styles.css:13681` opens a nested skin block, +`html:not([data-skin="og"]) { ... }`, and the **default skin is `daylight-blue`, not `og`**, so the +block is live for every default-skin user. Inside it, `.btn-toolbar.btn-run` is re-declared +generically and per-mode only for claude/opencode/codex (codex at `:13787`). CSS nesting adds the +wrapper's specificity (the nested rules resolve to (0,3,1) vs (0,3,0) for +`.btn-toolbar.btn-run.mode-X`), so **gemini's and antigravity's toolbar gradients are dead on the +default skin**: both render the generic claude gradient today, still unfixed as of f39beb3. The +base-sheet rules (gemini/antigravity at `:4406`/`:4420`) only ever render on the `og` skin. Since +1.12+ styles.css itself documents this trap in comments (`:9214`, `:11091`), which confirms the +mechanism. + +Consequences for pi: + +- The toolbar gradient needs **two** rules: one in the base sheet (`:4420` area, for `og`), and one + **inside** the `13681` block next to codex's (`:13787` area), using the block's own idiom + (or the color is invisible to the average user). +- `mobile.css` phone-toolbar colors need `!important` on `background`/`border-color`/`color`, + exactly as the CLAUDE.md gotcha prescribes. Antigravity's phone block (`mobile.css:895-910`, + inside the `@media (max-width: 430px)` opened at `:338`) has no `!important` and is dead on the + default skin; do not copy that mistake. +- Three surfaces work from base rules alone (verified): run-mode **dots** (list at `:4506-4516`; + the skin block overrides only claude/opencode/codex/shell dots, so a base-sheet + `.run-mode-dot.pi` renders as authored), **tab badges**, and the **welcome button** (the skin + block overrides only claude/opencode/tunnel welcome buttons). +- Optional, separate cleanup (not this change): gemini/antigravity could get the same in-block + treatment to resurrect their colors. + +### 2.10 Local-echo policy: pi lands on the buffer overlay by default + +New since the first draft of this plan: the codex predictive-echo work (1.13+) introduced a +per-session echo policy in `_updateLocalEchoState()` (terminal-ui.js, `_localEchoPolicy` set at +`:2837`): `codex → 'predict'` (write-through predictive echo), `shell → 'off'`, **everything else +→ 'buffer'** (the `LocalEchoOverlay` that buffers typed text until Enter). Pi therefore gets the +buffer overlay on touch devices with zero edits, via the fallthrough. + +That default is a real open question, not a freebie: the codex history (issues #218/#219/#220/#222) +shows that a composer which re-renders per keystroke (live-filtering slash picker, server-side +cursor movement, wrap-as-you-type) is starved by buffer-until-Enter, and pi's editor is exactly +such a composer. Decision for v1: ship with the default `'buffer'` policy but make phone-profile +typing an explicit E2E gate (§7 step 4); if pi's editor mis-renders under the overlay, the cheap +fallback is forcing `'off'` for pi (one branch in `_updateLocalEchoState`), and teaching the +predict path pi's composer row is a follow-up, not a v1 requirement. +`test/local-echo-codex-gating.test.ts` pins the per-mode policy via +`it.each(['claude', 'gemini', 'opencode'])` lists (`:193`, `:376`); add `'pi'` to those lists once +the buffer decision is confirmed (or pin the `'off'` branch if that is the outcome). + +**RESULT (measured, pi 0.84.1, iPhone 14 Pro profile + a PTY-level A/B):** the buffer policy +**holds**; codex's failure mode does **not** reproduce. Pi's slash picker re-filters on the **whole +composer content**, not on per-keystroke deltas: a one-shot literal write of `/set` (what the overlay +flush does) filters the picker to `settings` **identically** to sending `/ s e t` as five separate +keystrokes, and the delayed `\r` then selects it and opens the settings menu. Prose prompts buffer +correctly (`pendingText` right, nothing on the PTY before Enter), flush on Enter, and are accepted as +a single prompt. `'pi'` was added to both `it.each` lists. The `'off'` fallback stays documented but +unused. + +--- + +## 3. Config surface: `PiConfig` to CLI flags + +```ts +/** Pi CLI session configuration */ +export interface PiConfig { + /** Model pattern or ID. Supports `provider/id` and a `:` suffix (e.g. `sonnet:high`). Passed via --model. */ + model?: string; + /** Provider name (anthropic, openai, google, ...). Passed via --provider. */ + provider?: string; + /** Reasoning level. Passed via --thinking. */ + thinking?: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** Continue the most recent session (-c). Per-cwd scoping is strongly implied upstream but not documented; treat as probable. */ + continueSession?: boolean; + /** Resume a specific session by ID or partial UUID (--session). Codeman deliberately accepts ids only, never paths. */ + resumeSessionId?: string; + /** + * Tri-state project trust (repo-local `.pi/` settings/extensions/skills, plus installing + * missing project packages): + * true -> --approve (trust for this run; loads and EXECUTES repository TypeScript) + * false -> --no-approve (force-deny; the trust prompt never appears) + * absent -> pi's own defaultProjectTrust (ask). + * Multi-user: MATERIALIZED to false for non-granted owners (§5.2). + */ + approveProjectTrust?: boolean; +} +``` + +Flag mapping in `buildPiCommand()` (new, `tmux-manager.ts`, directly after `buildAntigravityCommand` +at `:718-736`; every builder there regex-allowlists each user value and silently drops failures +because the result lands in a `bash -c "..."` string): + +| Field | Flag | Validation | +| --------------------- | ------------------------------- | --------------------------------------------------------------------------------- | +| `approveProjectTrust` | `--approve` / `--no-approve` / nothing | tri-state boolean, clamped (§5.2) | +| `model` | `--model ` | `/^[a-zA-Z0-9._\-/:]+$/` (`:` for `sonnet:high`, `/` for `openai/gpt-4o`) | +| `provider` | `--provider ` | `/^[a-z0-9-]+$/` | +| `thinking` | `--thinking ` | runtime allowlist of the 7 enum values (defense in depth beyond Zod) | +| `resumeSessionId` | `--session ` | `/^[a-zA-Z0-9._-]+$/` (same shape as `RESUME_ID_SAFE`, `:1021`; excludes paths on purpose) | +| `continueSession` | `-c` | boolean; **skipped when a valid `resumeSessionId` is present** (the two conflict) | + +**Not** wired in v1, with reasons: + +- `--api-key `: ⚠️ **never wire this.** It puts a provider secret on the spawn command line, + which is exactly what the socket-scoped `tmux setenv` discipline exists to prevent (visible in + `ps`, tmux server state, and logs). Listed here so nobody "helpfully" adds it later. +- `--tui-mode` (released in 0.84.0): never passed by Codeman. The main-screen default is the + friendly case for the browser terminal, and fullscreen remains the user's own runtime choice via + `/settings` (§2.2 is designed for that). `--use-theme` (still unreleased) likewise. +- `--name ` (`-n`): nice for `/resume` readability, but names contain spaces and would be the + first user-controlled value needing real shell quoting in `buildSpawnCommand`. Defer. +- `--no-session`: ephemeral mode fights respawn/resume. Defer. +- `-p`/`--print`, `--mode json`, `--mode rpc`: non-interactive transports, a different product shape + (§9). Note upstream already shipped a breaking change to JSON-mode `message_update` framing, so + any future consumer must assemble deltas. +- `--tools` / `--exclude-tools` / `--no-tools` / `--no-builtin-tools` (`-t`/`-xt`/`-nt`/`-nbt`): a + genuinely useful "read-only session" affordance (0.84.0 also added a `defaultTools` setting), but + it needs UI design. Follow-up. +- `-r`/`--resume` (interactive picker), `--fork`, `-e`/`--extension`, `--skill`, `--system-prompt`, + `--append-system-prompt`, `--export`, `--models`, `--list-models`: not session-manager concerns in + v1. (`-e` matters later: §9's extension follow-up notes CLI extensions load before trust + resolution.) + +--- + +## 4. Implementation phases + +### Phase 1: Backend core + +| File | Change | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `src/utils/pi-cli-resolver.ts` | **New**, mirror `antigravity-cli-resolver.ts` (65 lines: search-dir list, module-level cache with `''` negative sentinel, `which pi` first). Search dirs: `~/.local/bin`, `/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`. Add the `pi --version` sanity probe from §2.6 (execFileSync, cached, vitest-skipped). Export `resolvePiDir()`, `isPiAvailable()`, `getPiCliVersion()` | +| `src/utils/index.ts` | Re-export the three (resolver block `:30-36`) | +| `src/types/session.ts` | `SessionMode` union `:46`; **both `Extract` lists**: `RemoteCommandMode` `:48-51`, `DockerCommandMode` `:157-161` (§2.8); new `PiConfig` after `AntigravityConfig` (`:325-333`); `SessionState.piConfig` after `:486`; `@fileoverview` mode list `:11` + config list `:17` | +| `src/mux-interface.ts` | `piConfig?: PiConfig` on `CreateSessionOptions` (config block ends `:78`) and `RespawnPaneOptions` (ends `:109`) | +| `src/session.ts` | `isExternalCliMode()` `:164-167` (+pi); `getModeLabel()` `:168-183` (+`'Pi'`); `_piConfig` field decl `:466-470`; ctor option `:556-563` + apply `:652-654`; `toState()` `:1227-1230`; `_buildRespawnPaneOptions()` `:1466-1469` (single source of truth shared by `startInteractive` and `reattachRemote`); `startInteractive()` createSessionOptions `:1680-1683`; COLORTERM attach-env condition `:1400-1402` (+pi); requires-tmux guard chain `:1751-1768` (new block: "Pi sessions require tmux for env override injection via setenv") | +| `src/tmux-manager.ts` | `buildPiCommand()` after `:736` per §3; `buildSpawnCommand()` signature `:770-779` + dispatch branch after `:822-825`; `appendResumeFlag()` `:1030-1042` (`case 'pi': return \`${modeCommand} --session ${resumeId}\`;`); `buildEnvExports()` truecolor branches `:1604-1609` (+pi); `buildPathExport()` `:1680-1707` (+pi branch calling `resolvePiDir()`); missing-CLI error chain in `createSession` `:1788-1806` (+pi, install hint `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`; note `respawnPane` deliberately has no such check); `piConfig` threading at the four sites `:1748`, `:1817`, `:2041`, `:2080`. **No `_configurePi`** (§2.3) | +| `src/config/dependency-registry.ts` | New entry after antigravity's (`:101-108`; file unchanged since 2026-08-06): `{ id: 'pi', label: 'Pi CLI', category: 'core', required: false, usedBy: ['Pi sessions'], resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['pi'], versionArg: '--version' } }] }` | +| `src/docker-hosts.ts` | `defaultDockerCommandForMode` `:138-149`: `pi: 'exec pi'`. `CRED_STORES` `:597-605`: the `.pi/agent` seedFiles entry per §2.5 (nested `rel` already handled at `:613-645`). File unchanged since 2026-08-06 | +| `src/remote-hosts.ts` | `defaultRemoteCommandForMode` `:92-118`: `pi: remoteLoginShellCommand('pi')` (`remoteLoginShellCommand` at `:88-90`). Login-shell routing is mandatory (the #209/e803186 lesson: ssh remote-command exec sees only sshd's minimal PATH, and npm's global bin is usually only on PATH via rc files) | + +### Phase 2: Web layer + +| File | Change | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `src/web/schemas.ts` | `'PI_'` in `ALLOWED_ENV_PREFIXES` `:125` **and** the prose error message `:163` (which now also names `CLAUDE_CONFIG_DIR`; the `ALLOWED_ENV_KEYS` exact-key set needs no change); new `PiConfigSchema` after `AntigravityConfigSchema` (`:256-271`), mirroring §3's regexes, `.optional()`, not `.strict()`; `piConfig` on `CreateSessionSchema` (`:299` area) and `QuickStartSchema` (`:712` area); `'pi'` in all three mode enums (`:285`, `:708`, cron `agentType` `:1214`; they are byte-identical and there is no fourth); `pi` key in `RemoteCommandOverridesSchema` `:426-436` (it is `.strict()`, so an unknown key is a hard error today; one edit covers both remote `:501` and docker `:577` reuse) | +| `src/web/routes/session-routes.ts` | Thread `piConfig` through create (`POST /api/sessions`): disk-strip exclusion chain `:705-712`, availability gate `:782-790` (+`isPiAvailable` with install-hint error), model resolution `:825-838` (`mode === 'pi' ? body.piConfig?.model : ...`), clamp call `:845`, Session ctor `:860` (`piConfig: mode === 'pi' ? gatedPiConfig : undefined`). Quick-start (`POST /api/quick-start`, handler `:2559`): remote-case config rejection `:2614-2621` and docker-case `:2645-2652` (+`piConfig`: per-CLI config does not cross ssh or the bind mount), hooks-scaffold exclusions `:2801`/`:2809`, availability gate `:2744-2752` (local-case branch only), env-strip chains `:2833`/`:2863`, model resolution `:2885`, clamp `:2897`, ctor `:2913`. **Extend `clampExternalCliBypassForOwner()`** (`:305-336`, doc comment above): fifth param + return field; pi joins the **materialize** branch per §5.2. Alt-screen replay-strip at `:2275` unchanged (pi not in it, §2.2) | +| `src/web/routes/system-routes.ts` | `GET /api/pi/status` after the antigravity handler (`:418-426`; file unchanged since 2026-08-06), same shape plus `version` (§2.6); update the "CLI Integrations" prose comment `:377` | +| `src/web/server.ts` | Restore path: `piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined` after `:2636`. **`renderIndexHtml` CLI-availability injection `:1375-1407`**: add `isPiAvailable` to the dynamic-import tuple (`:1382`) and a `pi` key to the injected object (`:1399`). Per §2.8 a missing key reads as *available*, so this is a correctness edit, not polish | + +### Phase 3: Frontend + +The antigravity touchpoints are the template. Since the first draft, the settings-surface overhaul +moved most anchors and added one **new touchpoint** (the clone-repo Brain picker below). +`constants.js`, `api-client.js`, `ralph-wizard.js`, `cron-ui.js`, `webview-tabs.js` and `sw.js` +still need **no** changes (re-verified zero mode coupling at f39beb3; cron-ui reads the `

@@ -2481,6 +2489,7 @@ + Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown. @@ -2621,7 +2630,7 @@
- Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy + tmux. + Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy/pi + tmux.
diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 88fe9dfb..864017a1 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [ { mode: 'codex', label: 'Codex', short: 'Codex' }, { mode: 'gemini', label: 'Gemini', short: 'Gemini' }, { mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' }, + { mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, ]; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index dc12c0cd..c83e54ed 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -911,6 +911,25 @@ html.mobile-init .file-browser-panel { border-color: rgba(34, 211, 238, 0.5); } + /* Pi mode colors on mobile. + `!important` is load-bearing here, not noise: styles.css nests its skin rules + inside `html:not([data-skin="og"])`, so a bare `.btn-toolbar.btn-run` in there + resolves to (0,2,1) and outranks this (0,2,0) `.mode-pi` pair regardless of + load order. The antigravity block right above omits it and is consequently + dead on every non-og skin (i.e. on the default) — do not copy that. */ + .btn-toolbar.btn-run.mode-pi, + .btn-toolbar.btn-run-gear.mode-pi { + background: #33121f !important; + border-color: rgba(244, 114, 182, 0.3) !important; + color: #fce7f3 !important; + } + + .btn-toolbar.btn-run.mode-pi:active, + .btn-toolbar.btn-run-gear.mode-pi:active { + background: #9d174d !important; + border-color: rgba(244, 114, 182, 0.5) !important; + } + /* Run mode dropdown menu — positioned above toolbar on mobile */ .run-mode-menu { bottom: 100%; @@ -2988,6 +3007,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-pi, .btn-toolbar.btn-run-gear.mode-pi) { + background: linear-gradient(135deg, #be185d, #db2777); + border-color: #9d174d; + 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; } diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index f5cafc85..419bd85e 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -427,7 +427,7 @@ Object.assign(CodemanApp.prototype, { _buildCommandPaletteNewSessionItem(query = '') { const mode = this.runMode || this._runMode || 'claude'; - const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity' }; + const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 81b5beff..183e8f72 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1,5 +1,5 @@ /** - * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity), + * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi), * session options modal (per-session settings, color picker, rename), * session options tabs (Ralph config tab), case settings (CRUD, links), * create case modal, and mobile case picker. @@ -400,6 +400,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'antigravity') { return await this.runAntigravity(); } + if (mode === 'pi') { + return await this.runPi(); + } if (mode === 'shell') { return await this.runShell(); } @@ -461,11 +464,11 @@ Object.assign(CodemanApp.prototype, { * `.run-mode-option` is also the class the saved-dashboard rows and the history * rows use, and a bare querySelector would find whichever came first in the DOM. * - * Antigravity is in this list even though #201 predates it — it is a run mode - * like the rest, and `agy` is the LEAST likely of the five to be installed. + * Antigravity and Pi are in this list even though #201 predates them — they are + * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; } @@ -562,7 +565,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 === 'antigravity' ? 'Run AG' : mode === 'shell' ? 'Run SH' : 'Run'; + label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run'; } }, @@ -1218,6 +1221,63 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Launch a Pi (pi.dev) session. + * + * Deliberately sends NO piConfig: pi has no permission prompts, so there is no + * bypass to opt into, and project trust is pi's own `defaultProjectTrust` + * decision (an interactive prompt the user answers in the terminal). Sending + * `approveProjectTrust: true` here would silently opt every browser-launched pi + * session into executing repo-supplied TypeScript. + */ + async runPi() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // Remote/docker cases run pi on the OTHER side — skip the local status probe and the + // local-only config/env below (quick-start rejects them for remote cases). + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); + this.terminal.focus(); + + try { + if (!isRemote) { + const statusRes = await fetch('/api/pi/status'); + const status = (await statusRes.json()).data; + if (!status.available) { + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' + ); + 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: 'pi', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, + ...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }), + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start Pi'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + + if (data.data.sessionId) { + await this.selectSession(data.data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } + }, + // ═══════════════════════════════════════════════════════════════ // Session Options Modal @@ -1230,7 +1290,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' || session.mode === 'antigravity'; + const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons @@ -1260,7 +1320,7 @@ Object.assign(CodemanApp.prototype, { } // Hide Claude-specific options for external CLI sessions - const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity'; + const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); @@ -2952,7 +3012,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', { }, set(mode) { this._runMode = - mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude' + mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'claude' ? mode : 'claude'; }, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0316e450..8eb8d869 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1179,6 +1179,7 @@ Object.assign(CodemanApp.prototype, { ['welcomeOpencodeBtn', 'opencode'], ['welcomeAntigravityBtn', 'antigravity'], ['welcomeGeminiBtn', 'gemini'], + ['welcomePiBtn', 'pi'], // Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box // without cloudflared can only ever produce "cloudflared not found". ['welcomeTunnelBtn', 'cloudflared'], diff --git a/src/web/public/styles.css b/src/web/public/styles.css index eb388c34..50bc975a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -329,7 +329,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat .search-badge-session, .history-view-all-btn, .session-tab .tab-mode.gemini, - .session-tab .tab-mode.antigravity + .session-tab .tab-mode.antigravity, + .session-tab .tab-mode.pi ) { color: var(--accent-d); } @@ -2159,6 +2160,11 @@ body.solo-mode .btn-lifecycle-log { color: #22d3ee; } +.session-tab .tab-mode.pi { + background: rgba(244, 114, 182, 0.2); + color: #f472b6; +} + /* Timer Banner - Compact */ .timer-banner { display: flex; @@ -3378,6 +3384,23 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { transform: translateY(-1px); } +/* Pi (pi.dev): rose identity, matching .btn-toolbar.btn-run.mode-pi and + .run-mode-dot.pi so the welcome action reads as the same backend. */ +.welcome-btn-pi { + background: linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%); + border-color: rgba(244, 114, 182, 0.4); + color: #fce7f3; + box-shadow: 0 2px 8px rgba(244, 114, 182, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.welcome-btn-pi:hover { + background: linear-gradient(135deg, #4a1a2c 0%, #be185d 55%, #db2777 100%); + box-shadow: 0 4px 20px rgba(244, 114, 182, 0.3), 0 0 40px rgba(190, 24, 93, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(249, 168, 212, 0.5); + color: #fff1f7; + transform: translateY(-1px); +} + .welcome-btn-gemini { background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%); border-color: rgba(96, 165, 250, 0.4); @@ -4432,6 +4455,26 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #ecfeff; } +/* Pi mode colors. NOTE: this base-sheet pair only renders on the `og` skin — the + nested `html:not([data-skin="og"])` block further down re-declares + `.btn-toolbar.btn-run` at a HIGHER specificity, which is why gemini's and + antigravity's gradients are dead on the default skin. Pi therefore also carries + a rule inside that block (search `.btn-toolbar.btn-run.mode-pi`). */ +.btn-toolbar.btn-run.mode-pi, +.btn-toolbar.btn-run-gear.mode-pi { + background: linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%); + border-color: rgba(244, 114, 182, 0.5); + color: #fce7f3; + 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-pi:hover, +.btn-toolbar.btn-run-gear.mode-pi:hover { + background: linear-gradient(135deg, #4a1a2c 0%, #be185d 55%, #db2777 100%); + box-shadow: 0 0 12px rgba(244, 114, 182, 0.35), 0 2px 8px rgba(190, 24, 93, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(249, 168, 212, 0.6); + color: #fff1f7; +} + /* Dropdown menu */ .run-mode-menu { display: none; @@ -4514,6 +4557,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .run-mode-dot.codex { background: #a855f7; } .run-mode-dot.gemini { background: #8ab4f8; } .run-mode-dot.antigravity { background: #22d3ee; } +.run-mode-dot.pi { background: #f472b6; } .run-mode-dot.shell { background: #94a3b8; } /* Phone-only Enter button (see index.html). Hidden by default at every width; @@ -13790,6 +13834,17 @@ html:not([data-skin="og"]) { color: #061c20; } .btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); } +/* Pi keeps its rose identity on the non-og skins. This rule has to live INSIDE + this nested block: the generic `.btn-toolbar.btn-run` above resolves to (0,3,1) + here and would otherwise beat the base sheet's (0,3,0) `.mode-pi` pair, which + is exactly why gemini's and antigravity's gradients render as generic claude + blue on the default skin. */ +.btn-toolbar.btn-run.mode-pi { + background: linear-gradient(135deg, #be185d, #f472b6); + border-color: #be185d; + color: #fff1f7; +} +.btn-toolbar.btn-run.mode-pi:hover { box-shadow: 0 0 14px -2px rgba(244, 114, 182, 0.45); } .btn-toolbar.btn-run-gear { background: var(--accent-d); border-color: var(--accent); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index eb37ee8b..d9f6b9d6 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1747,7 +1747,7 @@ Object.assign(CodemanApp.prototype, { } titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir))); - // Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill. + // Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/shell) + a LIVE pill. const badgeRow = document.createElement('div'); badgeRow.className = 'history-item-badges'; if (s.mode) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 10083174..59c10686 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -22,6 +22,7 @@ import { type CodexConfig, type GeminiConfig, type AntigravityConfig, + type PiConfig, } from '../../types.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; @@ -312,29 +313,50 @@ export function _resetPasteRateBuckets(): void { * 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()). + * + * Pi has no permission prompts at all, so there is no bypass switch to clamp; its + * privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE + * repo-local `.pi/extensions` TypeScript and npm-install missing project packages. + * Pi joins the gemini-style MATERIALIZE branch, not the codex/antigravity + * only-if-sent one: pi's absent-config default is an interactive trust prompt the + * session user could simply answer "yes" to in the terminal, so merely omitting + * `--approve` is not a clamp. Forcing `approveProjectTrust: false` makes + * buildPiCommand emit `--no-approve`, and the prompt never appears. */ async function clampExternalCliBypassForOwner( owner: string | undefined, codexConfig: CodexConfig | undefined, geminiConfig: GeminiConfig | undefined, - antigravityConfig: AntigravityConfig | undefined + antigravityConfig: AntigravityConfig | undefined, + piConfig: PiConfig | undefined ): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined; antigravityConfig: AntigravityConfig | undefined; + piConfig: PiConfig | undefined; }> { const granted = await canUsernameRunPrivilegedCommands(owner); - if (granted) return { codexConfig, geminiConfig, antigravityConfig }; + if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig }; // 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). + // sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default) + // and pi to --no-approve (clamps an explicit true AND pi's own "ask" default). const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig; const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' }; const clampedAntigravity = antigravityConfig ? { ...antigravityConfig, dangerouslySkipPermissions: false } : antigravityConfig; - return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity }; + const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false }; + return { + codexConfig: clampedCodex, + geminiConfig: clampedGemini, + antigravityConfig: clampedAntigravity, + piConfig: clampedPi, + }; } +/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */ +export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; + // ═══════════════════════════════════════════════════════════════ // Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input) // ═══════════════════════════════════════════════════════════════ @@ -706,6 +728,7 @@ export function registerSessionRoutes( body.mode !== 'codex' && body.mode !== 'gemini' && body.mode !== 'antigravity' && + body.mode !== 'pi' && body.envOverrides && Object.keys(body.envOverrides).length > 0 && (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); @@ -788,6 +811,15 @@ export function registerSessionRoutes( ); } } + if (body.mode === 'pi') { + const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js'); + if (!isPiAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' + ); + } + } // Pre-validate resumeSessionId: check that the conversation file actually exists // in Claude's projects directory. If not, skip resume to avoid confusing @@ -831,9 +863,11 @@ export function registerSessionRoutes( ? body.geminiConfig?.model : mode === 'antigravity' ? body.antigravityConfig?.model - : mode !== 'shell' - ? modelConfig?.defaultModel || undefined - : undefined; + : mode === 'pi' + ? body.piConfig?.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); @@ -842,7 +876,14 @@ export function registerSessionRoutes( codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig, antigravityConfig: gatedAntigravityConfig, - } = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig); + piConfig: gatedPiConfig, + } = await clampExternalCliBypassForOwner( + owner, + body.codexConfig, + body.geminiConfig, + body.antigravityConfig, + body.piConfig + ); const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir, @@ -858,6 +899,7 @@ export function registerSessionRoutes( codexConfig: mode === 'codex' ? gatedCodexConfig : undefined, geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined, antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined, + piConfig: mode === 'pi' ? gatedPiConfig : undefined, resumeSessionId: validatedResumeId, envOverrides: body.envOverrides, effort: body.effort, @@ -2570,6 +2612,7 @@ export function registerSessionRoutes( codexConfig, geminiConfig, antigravityConfig, + piConfig, envOverrides, effort, parentSessionId, @@ -2617,6 +2660,7 @@ export function registerSessionRoutes( codexConfig || geminiConfig || antigravityConfig || + piConfig || openCodeConfig ) { return createErrorResponse( @@ -2648,6 +2692,7 @@ export function registerSessionRoutes( codexConfig || geminiConfig || antigravityConfig || + piConfig || openCodeConfig ) { return createErrorResponse( @@ -2751,6 +2796,17 @@ export function registerSessionRoutes( } } + // Check Pi availability if requested + if (mode === 'pi') { + const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js'); + if (!isPiAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' + ); + } + } + // 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. @@ -2798,7 +2854,7 @@ export function registerSessionRoutes( // Write .claude/settings.local.json with hooks for desktop notifications // (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems) - if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') { + if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi') { await writeHooksConfig(resolvedCasePath); } @@ -2833,7 +2889,8 @@ export function registerSessionRoutes( mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && - mode !== 'antigravity' + mode !== 'antigravity' && + mode !== 'pi' ) { try { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { @@ -2864,6 +2921,7 @@ export function registerSessionRoutes( mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && + mode !== 'pi' && !remote && envOverrides && Object.keys(envOverrides).length > 0 @@ -2884,9 +2942,11 @@ export function registerSessionRoutes( ? geminiConfig?.model : mode === 'antigravity' ? antigravityConfig?.model - : mode !== 'shell' - ? qsModelConfig?.defaultModel || undefined - : undefined; + : mode === 'pi' + ? piConfig?.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/Antigravity bypass switches for a non-granted owner (no-op single-user/granted). @@ -2894,7 +2954,8 @@ export function registerSessionRoutes( codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, - } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig); + piConfig: qsGatedPiConfig, + } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir: resolvedCasePath, @@ -2911,6 +2972,7 @@ export function registerSessionRoutes( codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined, geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, + piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, envOverrides, effort, remote, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 64e43c76..87953851 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -374,7 +374,7 @@ export function registerSystemRoutes( }); // ═══════════════════════════════════════════════════════════════ - // CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity) + // CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi) // ═══════════════════════════════════════════════════════════════ // ========== Claude ========== @@ -425,6 +425,21 @@ export function registerSystemRoutes( }; }); + // ========== Pi ========== + + // Carries `version` on top of the sibling shape: `pi` is a short, generic binary + // name, so the resolver sanity-probes `pi --version` and rejects anything that + // is not the coding agent. Surfacing path + version makes a misresolution + // diagnosable from the UI instead of presenting as "the mode just doesn't work". + app.get('/api/pi/status', async () => { + const { isPiAvailable, resolvePiDir, getPiCliVersion } = await import('../../utils/pi-cli-resolver.js'); + return { + available: isPiAvailable(), + path: resolvePiDir(), + version: getPiCliVersion(), + }; + }); + // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 991fa827..1f2366c3 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -122,7 +122,7 @@ export const FileWriteSchema = z // ========== Env Var Allowlist ========== /** Allowlisted env var key prefixes */ -const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_']; +const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_', 'PI_']; /** * Allowlisted exact env var keys (checked alongside the prefixes). @@ -161,7 +161,7 @@ const safeEnvOverridesSchema = z }, { message: - 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_* keys and CLAUDE_CONFIG_DIR are allowed.', + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_* keys and CLAUDE_CONFIG_DIR are allowed.', } ); @@ -269,6 +269,37 @@ const AntigravityConfigSchema = z }) .optional(); +/** + * Schema for Pi CLI (pi.dev)-specific configuration. + * + * No bypass field exists on purpose: pi has no permission prompts. The one + * privilege-shaped knob is the TRI-STATE `approveProjectTrust` (see PiConfig), + * which the multi-user clamp MATERIALIZES to `false` for non-granted owners. + */ +const PiConfigSchema = z + .object({ + // `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`. + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/:]+$/) + .optional(), + provider: z + .string() + .max(50) + .regex(/^[a-z0-9-]+$/) + .optional(), + thinking: z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional(), + continueSession: z.boolean().optional(), + resumeSessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/) + .optional(), + approveProjectTrust: z.boolean().optional(), + }) + .optional(); + /** * The session that spawned the one being created — pure UI decoration, drawn as a * lineage line between the two tabs. Accepted here and, equivalently, as the @@ -282,7 +313,7 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(), + mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -297,6 +328,7 @@ export const CreateSessionSchema = z.object({ codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, antigravityConfig: AntigravityConfigSchema, + piConfig: PiConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() @@ -431,6 +463,7 @@ const RemoteCommandOverridesSchema = z codex: z.string().min(1).max(300).optional(), gemini: z.string().min(1).max(300).optional(), antigravity: z.string().min(1).max(300).optional(), + pi: z.string().min(1).max(300).optional(), }) .strict() .optional(); @@ -705,11 +738,12 @@ 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', 'antigravity']).optional(), + mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, antigravityConfig: AntigravityConfigSchema, + piConfig: PiConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -1211,7 +1245,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', 'antigravity']), + agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), diff --git a/src/web/server.ts b/src/web/server.ts index 285ff496..4231b162 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1380,6 +1380,7 @@ export class WebServer extends EventEmitter { { isCodexAvailable }, { isGeminiAvailable }, { isAntigravityAvailable }, + { isPiAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, ] = await Promise.all([ @@ -1388,6 +1389,7 @@ export class WebServer extends EventEmitter { import('../utils/codex-cli-resolver.js'), import('../utils/gemini-cli-resolver.js'), import('../utils/antigravity-cli-resolver.js'), + import('../utils/pi-cli-resolver.js'), import('../utils/cloudflared-resolver.js'), import('../git-clone.js'), ]); @@ -1397,6 +1399,7 @@ export class WebServer extends EventEmitter { codex: isCodexAvailable(), gemini: isGeminiAvailable(), antigravity: isAntigravityAvailable(), + pi: isPiAvailable(), cloudflared: isCloudflaredAvailable(), // Not a run mode: the Add Case → Clone tab is an offer this box cannot // keep without git (issue #236), same reasoning as cloudflared above. @@ -2634,6 +2637,7 @@ export class WebServer extends EventEmitter { codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined, geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined, antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined, + piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, attachmentHistory: savedAttachmentHistory, diff --git a/test/local-echo-codex-gating.test.ts b/test/local-echo-codex-gating.test.ts index e3570159..8dab9c2e 100644 --- a/test/local-echo-codex-gating.test.ts +++ b/test/local-echo-codex-gating.test.ts @@ -190,7 +190,7 @@ describe('_updateLocalEchoState mode gating', () => { expect(app._localEchoEnabled).toBe(false); }); - it.each(['claude', 'gemini', 'opencode'])('keeps the overlay enabled for %s sessions', (mode) => { + it.each(['claude', 'gemini', 'opencode', 'pi'])('keeps the overlay enabled for %s sessions', (mode) => { const overlay = makeOverlay(); const app = makeApp(mode, overlay); app._updateLocalEchoState(); @@ -373,16 +373,19 @@ describe('_updateLocalEchoState echo policy', () => { expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); }); - it.each(['claude', 'gemini', 'opencode'])("%s -> policy 'buffer' + overlay enabled (existing behavior)", (mode) => { - const overlay = makeOverlay(); - const app = makeApp(mode, overlay) as PredictiveApp; - app._predictiveEcho = makePredictor(); - app._updateLocalEchoState(); - expect(app._localEchoPolicy).toBe('buffer'); - expect(app._localEchoEnabled).toBe(true); - expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called - expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared - }); + it.each(['claude', 'gemini', 'opencode', 'pi'])( + "%s -> policy 'buffer' + overlay enabled (existing behavior)", + (mode) => { + const overlay = makeOverlay(); + const app = makeApp(mode, overlay) as PredictiveApp; + app._predictiveEcho = makePredictor(); + app._updateLocalEchoState(); + expect(app._localEchoPolicy).toBe('buffer'); + expect(app._localEchoEnabled).toBe(true); + expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called + expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared + } + ); it('no active session -> policy off, no crash without a predictor instance', () => { const app = makeApp('codex') as PredictiveApp; diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index c9874e31..776dd7a4 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -372,7 +372,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { isCliAvailable: () => true, }); const menu = app._buildMobileOverviewRunMenu(); - expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']); + expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']); }); it('gates every mode the picker actually offers', () => { diff --git a/test/pi-mode.test.ts b/test/pi-mode.test.ts new file mode 100644 index 00000000..1c5363ad --- /dev/null +++ b/test/pi-mode.test.ts @@ -0,0 +1,191 @@ +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('Pi mode schemas', () => { + it('accepts Pi session creation config', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + piConfig: { + model: 'sonnet:high', + provider: 'anthropic', + thinking: 'high', + }, + }); + + expect(parsed.mode).toBe('pi'); + expect(parsed.piConfig).toEqual({ + model: 'sonnet:high', + provider: 'anthropic', + thinking: 'high', + }); + }); + + it('accepts Pi quick-start config', () => { + const parsed = QuickStartSchema.parse({ + caseName: 'pi-case', + mode: 'pi', + piConfig: { resumeSessionId: '0f9c2b14-aa10', continueSession: true }, + }); + + expect(parsed.mode).toBe('pi'); + expect(parsed.piConfig?.resumeSessionId).toBe('0f9c2b14-aa10'); + }); + + it('accepts a provider-qualified model (`openai/gpt-4o`)', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + piConfig: { model: 'openai/gpt-4o' }, + }); + expect(parsed.piConfig?.model).toBe('openai/gpt-4o'); + }); + + it('rejects unsafe Pi model strings', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + piConfig: { model: 'pi; rm -rf /' }, + }) + ).toThrow(); + }); + + it('rejects unsafe Pi provider strings', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + piConfig: { provider: 'anthropic`whoami`' }, + }) + ).toThrow(); + }); + + it('rejects unsafe Pi resumeSessionId values (ids only, never paths)', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + piConfig: { resumeSessionId: '../../etc/passwd' }, + }) + ).toThrow(); + }); + + it('rejects thinking levels outside pi’s enum', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + piConfig: { thinking: 'ultra' }, + }) + ).toThrow(); + }); + + it('allows PI_* env overrides but NOT bare provider keys', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + envOverrides: { PI_OFFLINE: '1' }, + }); + expect(parsed.envOverrides).toEqual({ PI_OFFLINE: '1' }); + + // Pi's ~34 provider key vars share no prefix, and ALLOWED_ENV_PREFIXES is a single + // GLOBAL list with no mode context — allowlisting them for pi would widen the + // allowlist for every mode at once. They stay out; auth goes through pi's /login. + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'pi', + envOverrides: { ANTHROPIC_API_KEY: 'sk-test' }, + }) + ).toThrow(); + }); +}); + +describe('Pi spawn command', () => { + it('builds a bare pi command when no config is sent (pi has no permission prompts)', () => { + const cmd = buildSpawnCommand({ mode: 'pi', sessionId: 'abc12345' }); + expect(cmd).toBe('pi'); + }); + + it('maps model/provider/thinking to flags', () => { + const cmd = buildSpawnCommand({ + mode: 'pi', + sessionId: 'abc12345', + piConfig: { model: 'sonnet:high', provider: 'anthropic', thinking: 'xhigh' }, + }); + expect(cmd).toBe('pi --model sonnet:high --provider anthropic --thinking xhigh'); + }); + + it('emits --approve for true and --no-approve for false (tri-state project trust)', () => { + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { approveProjectTrust: true } })).toBe( + 'pi --approve' + ); + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { approveProjectTrust: false } })).toBe( + 'pi --no-approve' + ); + // Absent = pi's own defaultProjectTrust; Codeman must not decide it. + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: {} })).toBe('pi'); + }); + + it('passes --session for resume and skips -c when both are present', () => { + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { resumeSessionId: '0f9c2b14' } })).toBe( + 'pi --session 0f9c2b14' + ); + + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { continueSession: true } })).toBe('pi -c'); + + // The two conflict upstream: a valid explicit session id wins. + expect( + buildSpawnCommand({ + mode: 'pi', + sessionId: 'a', + piConfig: { continueSession: true, resumeSessionId: '0f9c2b14' }, + }) + ).toBe('pi --session 0f9c2b14'); + }); + + it('drops unsafe values rather than escaping them (the result lands in `bash -c "..."`)', () => { + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { model: 'a`b' } })).toBe('pi'); + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { provider: 'x;id' } })).toBe('pi'); + expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { resumeSessionId: 'x; rm -rf /' } })).toBe('pi'); + // An out-of-enum thinking level never reaches the command line either. + expect( + buildSpawnCommand({ + mode: 'pi', + sessionId: 'a', + piConfig: { thinking: 'ultra' as unknown as 'high' }, + }) + ).toBe('pi'); + }); + + it('never emits --api-key (a provider secret must not reach the spawn line)', () => { + const cmd = buildSpawnCommand({ + mode: 'pi', + sessionId: 'a', + piConfig: { model: 'sonnet', provider: 'anthropic', approveProjectTrust: true }, + }); + expect(cmd).not.toContain('--api-key'); + }); +}); + +describe('Pi mode gates', () => { + it('is an external CLI mode (readiness/ralph/respawn gating)', () => { + expect(isExternalCliMode('pi')).toBe(true); + }); + + it('is NOT an alt-screen strip mode (main-screen TUI + runtime-switchable fullscreen)', () => { + expect(isAltScreenStripMode('pi')).toBe(false); + }); + + it('has docker/remote default commands', () => { + expect(defaultDockerCommandForMode('pi')).toBe('exec pi'); + // Routed through an interactive login shell so npm's global bin resolves — + // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). + expect(defaultRemoteCommandForMode('pi')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'pi\''); + }); +}); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 3ec99bd2..5084efff 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -17,6 +17,7 @@ import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js'; import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js'; import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js'; import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js'; +import { isPiAvailable } from '../src/utils/pi-cli-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isGitAvailable } from '../src/git-clone.js'; @@ -43,6 +44,11 @@ vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({ isAntigravityAvailable: vi.fn(() => false), resolveAntigravityDir: vi.fn(() => null), })); +vi.mock('../src/utils/pi-cli-resolver.js', () => ({ + isPiAvailable: vi.fn(() => false), + resolvePiDir: vi.fn(() => null), + getPiCliVersion: vi.fn(() => null), +})); vi.mock('../src/utils/cloudflared-resolver.js', () => ({ isCloudflaredAvailable: vi.fn(() => false), resolveCloudflaredPath: vi.fn(() => null), @@ -131,6 +137,7 @@ describe('WebServer.renderIndexHtml', () => { vi.mocked(isCodexAvailable).mockReturnValue(true); vi.mocked(isGeminiAvailable).mockReturnValue(false); vi.mocked(isAntigravityAvailable).mockReturnValue(false); + vi.mocked(isPiAvailable).mockReturnValue(true); vi.mocked(isCloudflaredAvailable).mockReturnValue(true); vi.mocked(isGitAvailable).mockReturnValue(true); const { server } = makeServer({}); @@ -144,6 +151,7 @@ describe('WebServer.renderIndexHtml', () => { codex: true, gemini: false, antigravity: false, + pi: true, cloudflared: true, git: true, }); @@ -158,6 +166,7 @@ describe('WebServer.renderIndexHtml', () => { isCodexAvailable, isGeminiAvailable, isAntigravityAvailable, + isPiAvailable, isCloudflaredAvailable, isGitAvailable, ]) { diff --git a/test/routes/external-cli-bypass-clamp.test.ts b/test/routes/external-cli-bypass-clamp.test.ts new file mode 100644 index 00000000..afef6cc3 --- /dev/null +++ b/test/routes/external-cli-bypass-clamp.test.ts @@ -0,0 +1,132 @@ +/** + * First coverage for `clampExternalCliBypassForOwner` (session-routes.ts), the + * multi-user §6.3 gate that keeps a NON-GRANTED owner from launching an external + * CLI with its safety switches off. It backs both `POST /api/sessions` and + * `POST /api/quick-start` and, until pi was added, had no tests at all. + * + * The helper has two shapes and the difference is the whole point: + * - only-if-sent (codex, antigravity): an ABSENT config already spawns safe, so + * only a sent config needs its flag forced off. + * - MATERIALIZE (gemini, pi): the absent-config default is itself unsafe for a + * non-granted owner (gemini's builder defaults to `yolo`; pi's default is an + * interactive trust prompt the session user could just answer "yes" to), so + * the clamp has to CREATE a config. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { _clampExternalCliBypassForOwner } from '../../src/web/routes/session-routes.js'; +import { createUser, invalidateUsersCache } from '../../src/user-store.js'; + +const PASSWORD = 'clamp-test-password'; + +describe('clampExternalCliBypassForOwner — single-user mode', () => { + it('passes every config through untouched (the gate is a no-op)', async () => { + const out = await _clampExternalCliBypassForOwner( + undefined, + { dangerouslyBypassApprovals: true }, + { approvalMode: 'yolo' }, + { dangerouslySkipPermissions: true }, + { approveProjectTrust: true } + ); + expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: true }); + expect(out.geminiConfig).toEqual({ approvalMode: 'yolo' }); + expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: true }); + expect(out.piConfig).toEqual({ approveProjectTrust: true }); + }); + + it('leaves absent configs absent', async () => { + const out = await _clampExternalCliBypassForOwner(undefined, undefined, undefined, undefined, undefined); + expect(out.codexConfig).toBeUndefined(); + expect(out.geminiConfig).toBeUndefined(); + expect(out.antigravityConfig).toBeUndefined(); + expect(out.piConfig).toBeUndefined(); + }); +}); + +describe('clampExternalCliBypassForOwner — multi-user mode', () => { + // The temp HOME from test/setup.ts is per-FILE, so users.json survives between + // tests here — create the three accounts once. + beforeAll(async () => { + process.env.CODEMAN_MULTIUSER = '1'; + invalidateUsersCache(); + await createUser({ username: 'boss', role: 'admin', password: PASSWORD }); + await createUser({ username: 'peon', role: 'user', password: PASSWORD }); + await createUser({ username: 'trusted', role: 'user', password: PASSWORD, canBypassPermissions: true }); + }); + + afterAll(() => { + delete process.env.CODEMAN_MULTIUSER; + invalidateUsersCache(); + }); + + it('passes through for an admin owner', async () => { + const out = await _clampExternalCliBypassForOwner( + 'boss', + { dangerouslyBypassApprovals: true }, + undefined, + { dangerouslySkipPermissions: true }, + { approveProjectTrust: true } + ); + expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: true }); + expect(out.geminiConfig).toBeUndefined(); + expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: true }); + expect(out.piConfig).toEqual({ approveProjectTrust: true }); + }); + + it('passes through for a user holding the bypass grant', async () => { + const out = await _clampExternalCliBypassForOwner('trusted', undefined, undefined, undefined, { + approveProjectTrust: true, + }); + expect(out.piConfig).toEqual({ approveProjectTrust: true }); + }); + + it('forces codex/antigravity bypass off for a non-granted owner (only-if-sent branch)', async () => { + const out = await _clampExternalCliBypassForOwner( + 'peon', + { dangerouslyBypassApprovals: true, model: 'gpt-5' }, + undefined, + { dangerouslySkipPermissions: true, model: 'gemini-3-pro' }, + undefined + ); + expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, model: 'gpt-5' }); + expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: false, model: 'gemini-3-pro' }); + }); + + it('leaves codex/antigravity absent when nothing was sent (they already spawn safe)', async () => { + const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined); + expect(out.codexConfig).toBeUndefined(); + expect(out.antigravityConfig).toBeUndefined(); + }); + + it('MATERIALIZES gemini to auto_edit even when no config was sent', async () => { + const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined); + expect(out.geminiConfig).toEqual({ approvalMode: 'auto_edit' }); + }); + + it('MATERIALIZES pi to --no-approve even when no config was sent', async () => { + // The load-bearing case: omitting --approve is NOT a clamp for pi, because + // pi's own default is to ASK, and the session user can answer that prompt. + const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined); + expect(out.piConfig).toEqual({ approveProjectTrust: false }); + }); + + it('forces a sent pi approveProjectTrust:true down to false, keeping other fields', async () => { + const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, { + approveProjectTrust: true, + model: 'sonnet:high', + provider: 'anthropic', + }); + expect(out.piConfig).toEqual({ + approveProjectTrust: false, + model: 'sonnet:high', + provider: 'anthropic', + }); + }); + + it('fails closed for an unknown/deleted owner', async () => { + const out = await _clampExternalCliBypassForOwner('ghost', undefined, undefined, undefined, { + approveProjectTrust: true, + }); + expect(out.piConfig).toEqual({ approveProjectTrust: false }); + expect(out.geminiConfig).toEqual({ approvalMode: 'auto_edit' }); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index a524340f..e121ba5d 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -86,6 +86,12 @@ vi.mock('../../src/utils/antigravity-cli-resolver.js', () => ({ resolveAntigravityDir: vi.fn(() => null), })); +vi.mock('../../src/utils/pi-cli-resolver.js', () => ({ + isPiAvailable: vi.fn(() => false), + resolvePiDir: vi.fn(() => null), + getPiCliVersion: vi.fn(() => null), +})); + import fs from 'node:fs/promises'; import { existsSync, readdirSync } from 'node:fs'; import { subagentWatcher } from '../../src/subagent-watcher.js'; @@ -93,6 +99,7 @@ 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'; +import { isPiAvailable, resolvePiDir, getPiCliVersion } from '../../src/utils/pi-cli-resolver.js'; const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); @@ -106,6 +113,9 @@ const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable); const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir); const mockedIsAntigravityAvailable = vi.mocked(isAntigravityAvailable); const mockedResolveAntigravityDir = vi.mocked(resolveAntigravityDir); +const mockedIsPiAvailable = vi.mocked(isPiAvailable); +const mockedResolvePiDir = vi.mocked(resolvePiDir); +const mockedGetPiCliVersion = vi.mocked(getPiCliVersion); describe('system-routes', () => { let harness: RouteTestHarness; @@ -839,6 +849,38 @@ describe('system-routes', () => { }); }); + // ========== GET /api/pi/status ========== + + describe('GET /api/pi/status', () => { + it('returns unavailable when pi is not installed', async () => { + mockedIsPiAvailable.mockReturnValue(false); + mockedResolvePiDir.mockReturnValue(null); + mockedGetPiCliVersion.mockReturnValue(null); + + const res = await harness.app.inject({ method: 'GET', url: '/api/pi/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.available).toBe(false); + expect(body.path).toBeNull(); + expect(body.version).toBeNull(); + }); + + it('returns available with path AND version when pi is installed', async () => { + // `version` is pi-specific: `pi` is a generic binary name, so the resolver + // version-probes it and this endpoint is where a misresolution shows up. + mockedIsPiAvailable.mockReturnValue(true); + mockedResolvePiDir.mockReturnValue('/home/user/.local/bin'); + mockedGetPiCliVersion.mockReturnValue('0.84.1'); + + const res = await harness.app.inject({ method: 'GET', url: '/api/pi/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'); + expect(body.version).toBe('0.84.1'); + }); + }); + // ========== GET /api/execution/model-config ========== describe('GET /api/execution/model-config', () => { diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 16defaac..17412297 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -168,7 +168,15 @@ describe('Run launch synchronization', () => { // Fail loudly if the scan matched nothing: a silently empty scan would make // every assertion below vacuously true. expect([...bodies.keys()]).toEqual( - expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity']) + expect.arrayContaining([ + 'runClaude', + 'runShell', + 'runOpenCode', + 'runCodex', + 'runGemini', + 'runAntigravity', + 'runPi', + ]) ); for (const [name, body] of bodies) { @@ -356,12 +364,13 @@ describe('Codex quick start settings', () => { 'welcomeOpencodeBtn', 'welcomeAntigravityBtn', 'welcomeGeminiBtn', + 'welcomePiBtn', 'welcomeTunnelBtn', ]) { welcomeBtns[id] = { style: { display: 'PRISTINE' } }; } const modeBtns: Record = {}; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']) { modeBtns[mode] = { style: { display: 'PRISTINE' } }; } const menu = { @@ -392,6 +401,7 @@ describe('Codex quick start settings', () => { codex: false, gemini: false, antigravity: false, + pi: false, cloudflared: false, }; @@ -410,6 +420,13 @@ describe('Codex quick start settings', () => { withTunnel.app.applyWelcomeCliVisibility(); expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex'); + // Pi is gated on `pi` like the rest; the resolver additionally version-probes + // the binary, so a stray `pi` on PATH reports unavailable rather than broken. + const withPi = loadUi({ ...ALL_OFF, pi: true }); + withPi.app.applyWelcomeCliVisibility(); + expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex'); + expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); + // Antigravity is a first-class welcome action, gated on `agy` like the rest. const withAgy = loadUi({ ...ALL_OFF, antigravity: true }); withAgy.app.applyWelcomeCliVisibility(); @@ -439,6 +456,7 @@ describe('Codex quick start settings', () => { (m) => m[1] ); expect(offered).toContain('antigravity'); + expect(offered).toContain('pi'); const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); // Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu. const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {')); @@ -889,3 +907,89 @@ describe('Antigravity quick start', () => { expect(selected).toEqual(['sess-ag']); }); }); + +describe('Pi quick start', () => { + // Same envelope-unwrap regression guard as the blocks above, for runPi(), plus the + // rule that makes pi different: it must send NO piConfig. Pi has no permission + // prompts, and `approveProjectTrust` would opt the session into EXECUTING + // repo-supplied TypeScript — never something a Run button decides silently. + it('drives runPi() through the {success,data} envelope and sends no piConfig', async () => { + const elements: Record = { + quickStartCase: { value: 'pi-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/pi/status') + return { + json: async () => ({ success: true, data: { available: true, path: '/usr/local/bin', version: '0.84.1' } }), + }; + if (url === '/api/quick-start') + return { json: async () => ({ success: true, data: { sessionId: 'sess-pi' } }) }; + if (url === '/api/sessions/sess-pi') + return { json: async () => ({ success: true, data: { id: 'sess-pi', name: 'w1-pi-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.runPi(); + + const body = requests.find((req) => req.url === '/api/quick-start')?.body; + expect(body).toMatchObject({ caseName: 'pi-case', mode: 'pi' }); + expect(body).not.toHaveProperty('piConfig'); + expect(selected).toEqual(['sess-pi']); + }); + + it('reports the install hint when the CLI is missing and starts nothing', async () => { + const elements: Record = { quickStartCase: { value: 'pi-case' } }; + const requests: string[] = []; + 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) => { + requests.push(url); + if (url === '/api/pi/status') + return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) }; + 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: () => {} }; + const errors: string[] = []; + app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg); + + await app.runPi(); + + expect(requests).toEqual(['/api/pi/status']); + expect(errors[0]).toContain('@earendil-works/pi-coding-agent'); + }); +}); From f4dcfbe6cafd6a65748fc1126845c7aadd82a8d4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 13 Aug 2026 17:23:27 +0200 Subject: [PATCH 2/4] fix(pi): close four mode-list gaps in the pi run mode Review follow-ups on #282. All four are the same failure shape: a list that enumerates run modes, missed by the sweep that added 'pi'. 1. Cron ignored pi's project-trust clamp. The PR widened CronJobBaseSchema's agentType to accept 'pi' but not the matching clamp beside gemini's, so a non-granted multi-user owner's cron pi job spawned bare `pi` (pi's own defaultProjectTrust, an interactive prompt they can answer "yes" to, which loads and EXECUTES repo-local .pi/extensions TypeScript) while the same user's UI/API launch was forced to --no-approve. The clamp is now a pure exported helper, clampCronExternalCliConfigs(), so both it and gemini's previously untested materialization are pinned. 2. POST /api/sessions/:id/interactive auto-enabled the Ralph tracker for pi: its denylist covered opencode/codex/gemini/antigravity only. The tracker is never fed for an external CLI (_processExpensiveParsers returns early), so a pi session reported ralphEnabled and Ralph UI state no sibling backend shows. 3. REMOTE_CLI_BIN had no pi entry, so buildRemoteCliVersionProbeCommand() returned null and Session.cliVersion stayed blank for every remote-SSH pi session, even though the PR wired the remote launch command and the per-mode override schema field. 4. The desktop home rail's badge map had no pi entry, and its lookup falls back to '', which is what claude renders. A pi session read as Claude there while the tab strip and phone overview badged it correctly. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/ba4bc996.md | 3 ++- src/cron/cron-service.ts | 40 +++++++++++++++++++++++++------- src/remote-hosts.ts | 1 + src/web/public/home-sessions.js | 1 + src/web/routes/session-routes.ts | 6 ++++- test/cron-service.test.ts | 35 +++++++++++++++++++++++++++- test/home-sessions.test.ts | 20 ++++++++++++++++ test/pi-mode.test.ts | 11 ++++++++- 8 files changed, 105 insertions(+), 12 deletions(-) diff --git a/.changeset/ba4bc996.md b/.changeset/ba4bc996.md index c0459dfe..c2750c30 100644 --- a/.changeset/ba4bc996.md +++ b/.changeset/ba4bc996.md @@ -8,9 +8,10 @@ Add Pi (pi.dev) as a sixth CLI run mode (#206). - **New resolver** `src/utils/pi-cli-resolver.ts`. Unlike the sibling resolvers it sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name that a stray binary on `$PATH` can shadow; the rejected path is logged. `GET /api/pi/status` returns `{ available, path, version }` so a misresolution is diagnosable. - **`PiConfig`** maps to `--model` (accepts `provider/id` and a `:thinking` suffix), `--provider`, `--thinking`, `--session`/`-c`, and the tri-state `--approve` / `--no-approve`. Every value is regex-allowlisted and dropped on failure. `--api-key` is deliberately never wired: it would put a provider secret on the spawn command line. -- **No bypass flag.** Pi has no permission prompts and no sandbox, so there is no `--dangerously-skip-permissions` analog. Its privilege-shaped knob is `approveProjectTrust`, which makes pi load and execute repo-local `.pi/extensions` TypeScript and install missing project packages. `clampExternalCliBypassForOwner()` therefore puts pi in the **materialize** branch: a non-granted multi-user owner gets `--no-approve` even when no config was sent, because pi's own default is an interactive prompt the session user could answer themselves. That helper had no test coverage at all; it now does, for all four CLIs. +- **No bypass flag.** Pi has no permission prompts and no sandbox, so there is no `--dangerously-skip-permissions` analog. Its privilege-shaped knob is `approveProjectTrust`, which makes pi load and execute repo-local `.pi/extensions` TypeScript and install missing project packages. `clampExternalCliBypassForOwner()` therefore puts pi in the **materialize** branch: a non-granted multi-user owner gets `--no-approve` even when no config was sent, because pi's own default is an interactive prompt the session user could answer themselves. The same materialization applies to cron-fired jobs (`clampCronExternalCliConfigs`), which carry no per-CLI config and would otherwise launch on pi's own default. Both helpers had no test coverage at all; they now do, for every CLI. - **Env allowlist gains only the `PI_*` prefix.** Pi's ~34 provider key vars share no prefix and `ALLOWED_ENV_PREFIXES` is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Users authenticate via pi's `/login` or the server process's own environment. - **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback, and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings` — verified to switch the pane into the alt screen, which the strip would have corrupted. - **Docker**: pi installs in its own `--ignore-scripts` step so that flag cannot affect the other four CLIs, and its credentials are seeded per-file (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json`) rather than whole-dir, since `~/.pi/agent` also holds sessions, extensions and installed package trees. - **Local echo**: pi lands on the buffer overlay. Verified that codex's per-keystroke starvation does not reproduce — pi's slash picker re-filters on the whole composer content, so a one-shot flush behaves identically to per-keystroke typing. +- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. - Installer detection, docs (`docs/pi-integration.md`), READMEs, and the architecture invariants are updated. Tests: `test/pi-mode.test.ts` and `test/routes/external-cli-bypass-clamp.test.ts`, plus extensions to the run-mode, mobile-overview, render-index-html, system-routes and local-echo suites. diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 63c83064..93370d44 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -27,7 +27,7 @@ import { validateSessionFilePath } from '../web/route-helpers.js'; import { computeNextRunAt, dueKeyFor } from './cron-time.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js'; import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js'; -import type { GeminiConfig } from '../types/session.js'; +import type { GeminiConfig, PiConfig, SessionMode } from '../types/session.js'; import type { CronJobInput } from './cron-input.js'; /** The subset of the route context the cron depends on. */ @@ -35,6 +35,32 @@ export type CronDeps = SessionPort & EventPort & ConfigPort & InfraPort; const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +/** + * Section 6.3 clamp for a cron-launched external CLI, mirroring + * `clampExternalCliBypassForOwner()` in session-routes.ts. + * + * A cron job carries NO per-CLI config, so what a non-granted owner actually gets is + * each CLI's SPAWN DEFAULT, and for two of them that default is itself unsafe: + * - gemini: `buildGeminiCommand(undefined)` emits `--approval-mode yolo` (classifier-free), + * so `auto_edit` is materialized. + * - pi: pi's own `defaultProjectTrust` is an interactive prompt the session user can simply + * answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript, + * so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve` + * is NOT a clamp. + * Codex and antigravity need nothing here: their absent config already spawns safe. + * Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched. + */ +export function clampCronExternalCliConfigs( + mode: SessionMode, + ownerGranted: boolean +): { geminiConfig: GeminiConfig | undefined; piConfig: PiConfig | undefined } { + if (ownerGranted) return { geminiConfig: undefined, piConfig: undefined }; + return { + geminiConfig: mode === 'gemini' ? { approvalMode: 'auto_edit' } : undefined, + piConfig: mode === 'pi' ? { approveProjectTrust: false } : undefined, + }; +} + /** Hard ceiling on a prompt-file read (defends against unbounded-read DoS). */ const MAX_PROMPT_FILE_BYTES = 1024 * 1024; @@ -371,13 +397,10 @@ export class CronService { const claudeModeConfig = await this.deps.getClaudeModeConfig(); const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner); const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined; - // Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined) - // would default a non-granted owner to `--approval-mode yolo` (classifier-free) — - // materialize auto_edit for a non-granted gemini owner, mirroring the route clamp - // (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent - // config already defaults to the safe sandbox, so no clamp is needed there. - const geminiConfig: GeminiConfig | undefined = - mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined; + // Section 6.3: materialize the safe default for a non-granted owner (see + // clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own + // spawn default is what would otherwise apply). + const { geminiConfig, piConfig } = clampCronExternalCliConfigs(mode, ownerGranted); session = new Session({ workingDir: job.workingDir, mode, @@ -389,6 +412,7 @@ export class CronService { claudeMode: effectiveClaudeMode, allowedTools: claudeModeConfig.allowedTools, geminiConfig, + piConfig, owner: job.owner, }); this.deps.addSession(session); diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 6a01870d..681962ee 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -268,6 +268,7 @@ const REMOTE_CLI_BIN: Partial> = { codex: 'codex', gemini: 'gemini', antigravity: 'agy', + pi: 'pi', }; /** diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index a52d7a2a..a08dce23 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -69,6 +69,7 @@ const HOME_SESSIONS_MODE_BADGE = { codex: 'cx', gemini: 'gm', antigravity: 'ag', + pi: 'pi', }; Object.assign(CodemanApp.prototype, { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 59c10686..d0b55fb8 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1114,12 +1114,16 @@ 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 / antigravity sessions + // Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions. + // Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early + // for those modes, so a tracker enabled here would never be fed, and the session would + // still report ralphEnabled + Ralph UI state that no other external CLI shows. if ( session.mode !== 'opencode' && session.mode !== 'codex' && session.mode !== 'gemini' && session.mode !== 'antigravity' && + session.mode !== 'pi' && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { diff --git a/test/cron-service.test.ts b/test/cron-service.test.ts index f8db095e..68ee369b 100644 --- a/test/cron-service.test.ts +++ b/test/cron-service.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { CronService, type CronDeps } from '../src/cron/cron-service.js'; +import { CronService, clampCronExternalCliConfigs, type CronDeps } from '../src/cron/cron-service.js'; import { CronJobSchema } from '../src/web/schemas.js'; import { MAX_CRON_JOBS } from '../src/config/map-limits.js'; import type { CronJob, CronJobRun } from '../src/types/cron.js'; @@ -632,3 +632,36 @@ describe('CronService', () => { }); }); }); + +/** + * The §6.3 clamp cron applies at FIRE time. Cron sends no per-CLI config, so a + * missing clamp here is not "the default applies" but "the CLI's own unsafe default + * applies", which is the whole reason gemini and pi are materialized rather than + * left absent like codex/antigravity. + */ +describe('clampCronExternalCliConfigs', () => { + it('leaves everything undefined for a granted owner (upstream defaults)', () => { + expect(clampCronExternalCliConfigs('gemini', true)).toEqual({ geminiConfig: undefined, piConfig: undefined }); + expect(clampCronExternalCliConfigs('pi', true)).toEqual({ geminiConfig: undefined, piConfig: undefined }); + }); + + it('materializes gemini auto_edit for a non-granted owner (its default is yolo)', () => { + expect(clampCronExternalCliConfigs('gemini', false)).toEqual({ + geminiConfig: { approvalMode: 'auto_edit' }, + piConfig: undefined, + }); + }); + + it('materializes pi --no-approve for a non-granted owner (its default is an answerable prompt)', () => { + expect(clampCronExternalCliConfigs('pi', false)).toEqual({ + geminiConfig: undefined, + piConfig: { approveProjectTrust: false }, + }); + }); + + it('clamps nothing for modes whose absent config already spawns safe', () => { + for (const mode of ['claude', 'shell', 'opencode', 'codex', 'antigravity'] as const) { + expect(clampCronExternalCliConfigs(mode, false)).toEqual({ geminiConfig: undefined, piConfig: undefined }); + } + }); +}); diff --git a/test/home-sessions.test.ts b/test/home-sessions.test.ts index abae5884..719b3b38 100644 --- a/test/home-sessions.test.ts +++ b/test/home-sessions.test.ts @@ -143,6 +143,26 @@ describe('home sessions column: model', () => { }); expect(plain.buildHomeSessionRows()[0].modeBadge).toBe(''); }); + + it('badges every non-claude backend, so a new run mode cannot read as claude here', () => { + // The badge map is a per-mode lookup with a '' fallback, so a mode missing from it + // is indistinguishable from claude in this rail while the tab strip badges it fine. + for (const [mode, badge] of [ + ['shell', 'sh'], + ['opencode', 'oc'], + ['codex', 'cx'], + ['gemini', 'gm'], + ['antigravity', 'ag'], + ['pi', 'pi'], + ] as const) { + const app = loadHomeSessionsApp({ + sessions: sessionMap([{ id: 'a', mode }]), + sessionOrder: ['a'], + cases: CASES, + }); + expect(app.buildHomeSessionRows()[0].modeBadge).toBe(badge); + } + }); }); describe('home sessions column: gate', () => { diff --git a/test/pi-mode.test.ts b/test/pi-mode.test.ts index 1c5363ad..c1d63938 100644 --- a/test/pi-mode.test.ts +++ b/test/pi-mode.test.ts @@ -2,7 +2,7 @@ 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 { defaultRemoteCommandForMode, buildRemoteCliVersionProbeCommand } from '../src/remote-hosts.js'; import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; describe('Pi mode schemas', () => { @@ -188,4 +188,13 @@ describe('Pi mode gates', () => { // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). expect(defaultRemoteCommandForMode('pi')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'pi\''); }); + + it('probes the CLI version on a remote host (REMOTE_CLI_BIN carries pi)', () => { + // Without the REMOTE_CLI_BIN entry this returns null and Session.cliVersion stays + // blank for every remote pi session, which is invisible until someone asks why the + // version column is empty on that host only. + const cmd = buildRemoteCliVersionProbeCommand({ username: 'dev', host: 'box.example', port: 22 }, 'pi'); + expect(cmd).not.toBeNull(); + expect(cmd).toContain('pi --version'); + }); }); From 86c78fece3266f4576dc9afeae18106cfedcfb20 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 13 Aug 2026 17:41:44 +0200 Subject: [PATCH 3/4] fix(pi): align the doctor with the pi resolver, correct the strip rationale, update the skill Second review pass on #282, the three items left open after f4dcfbe. 1. `codeman doctor` and the run mode disagreed about pi. The registry entry accepted a bare `which pi` hit while pi-cli-resolver demanded semver-shaped `--version` output, so the Dependencies panel could report an installed Pi CLI on a box where Run Pi stays hidden, which reads as a broken mode rather than a missing install. Both sides now share one exported PI_VERSION_REGEX, and PathResolver gains an opt-in `requireVersionMatch` so a binary that fails the shape check is reported MISSING instead of installed-with-unknown-version. Only pi sets it; every other tool keeps its current behaviour. 2. The isAltScreenStripMode comment justified excluding pi with "the alt screen is load-bearing for its fullscreen TUI". That is not what exclusion does: pi is tmux-backed, so it falls through to isMuxAltScreenOnlyStripMode, which strips the alt-screen toggles anyway. What exclusion actually preserves is `\x1b[3J` and the mouse DECSETs, which is the real reason (pi renders into the main screen and is mouse-aware). Comment and changeset now say that, and state the consequence: fullscreen pi paints into the main buffer, like vim in a tmux shell session. 3. skills/codeman still enumerated the five pre-pi modes in nine places, telling agents a backend does not exist and understating class-wide caveats by one mode. All updated, plus stale session.ts line references refreshed. Tests: a new static guard derives the mode set from the Zod schema (not a copy) and fails when a skill enumeration lists a partial set of external CLIs, verified by mutation. It also documents the one legitimate exception it found: the "writes no transcript" lists drop codex, which does write a rollout Codeman reads back. Plus doctor cases for an unrelated `pi` on PATH and registry/resolver regex parity. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/ba4bc996.md | 5 +- skills/codeman/SKILL.md | 7 +- skills/codeman/reference/endpoints.md | 10 +-- skills/codeman/reference/messaging.md | 4 +- skills/codeman/reference/recipes.md | 2 +- src/config/dependency-registry.ts | 27 ++++++- src/session.ts | 19 +++-- src/utils/dependency-checker.ts | 7 +- src/utils/pi-cli-resolver.ts | 18 ++++- test/agent-skill-mode-lists.test.ts | 103 ++++++++++++++++++++++++++ test/dependency-checker.test.ts | 63 ++++++++++++++++ 11 files changed, 242 insertions(+), 23 deletions(-) create mode 100644 test/agent-skill-mode-lists.test.ts diff --git a/.changeset/ba4bc996.md b/.changeset/ba4bc996.md index c2750c30..ef3f1819 100644 --- a/.changeset/ba4bc996.md +++ b/.changeset/ba4bc996.md @@ -10,8 +10,9 @@ Add Pi (pi.dev) as a sixth CLI run mode (#206). - **`PiConfig`** maps to `--model` (accepts `provider/id` and a `:thinking` suffix), `--provider`, `--thinking`, `--session`/`-c`, and the tri-state `--approve` / `--no-approve`. Every value is regex-allowlisted and dropped on failure. `--api-key` is deliberately never wired: it would put a provider secret on the spawn command line. - **No bypass flag.** Pi has no permission prompts and no sandbox, so there is no `--dangerously-skip-permissions` analog. Its privilege-shaped knob is `approveProjectTrust`, which makes pi load and execute repo-local `.pi/extensions` TypeScript and install missing project packages. `clampExternalCliBypassForOwner()` therefore puts pi in the **materialize** branch: a non-granted multi-user owner gets `--no-approve` even when no config was sent, because pi's own default is an interactive prompt the session user could answer themselves. The same materialization applies to cron-fired jobs (`clampCronExternalCliConfigs`), which carry no per-CLI config and would otherwise launch on pi's own default. Both helpers had no test coverage at all; they now do, for every CLI. - **Env allowlist gains only the `PI_*` prefix.** Pi's ~34 provider key vars share no prefix and `ALLOWED_ENV_PREFIXES` is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Users authenticate via pi's `/login` or the server process's own environment. -- **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback, and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings` — verified to switch the pane into the alt screen, which the strip would have corrupted. +- **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback and is mouse-aware, so it consumes `\x1b[3J` and the mouse DECSETs that the full strip removes, unlike an Ink TUI repainting in place. Note what exclusion does NOT do: pi is tmux-backed, so it still falls through to the narrow `isMuxAltScreenOnlyStripMode()` strip and its alt-screen toggles are dropped either way. Pi's runtime-switchable fullscreen TUI therefore paints into the main buffer, exactly like vim inside a tmux `shell` session. - **Docker**: pi installs in its own `--ignore-scripts` step so that flag cannot affect the other four CLIs, and its credentials are seeded per-file (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json`) rather than whole-dir, since `~/.pi/agent` also holds sessions, extensions and installed package trees. - **Local echo**: pi lands on the buffer overlay. Verified that codex's per-keystroke starvation does not reproduce — pi's slash picker re-filters on the whole composer content, so a one-shot flush behaves identically to per-keystroke typing. -- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. +- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. The packaged agent skill's mode enumerations list pi too, pinned by a new guard that derives the mode set from the Zod schema instead of restating it. +- **`codeman doctor` and the run mode agree about pi.** The registry entry resolved a bare `which pi` while `pi-cli-resolver` demanded semver output, so the Dependencies panel could report an installed Pi CLI that sessions refuse to launch. Both now share one exported regex, and the registry's new `requireVersionMatch` reports a non-semver `pi` as missing rather than installed. Only pi sets it; every other tool keeps its existing behaviour. - Installer detection, docs (`docs/pi-integration.md`), READMEs, and the architecture invariants are updated. Tests: `test/pi-mode.test.ts` and `test/routes/external-cli-bypass-clamp.test.ts`, plus extensions to the run-mode, mobile-overview, render-index-html, system-routes and local-echo suites. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 86336101..2c6560db 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -568,7 +568,7 @@ recovered by submitting it with `{"input":"\r"}`. ⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks, and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On -`shell`/`opencode`/`codex`/`gemini`/`antigravity`, requesting them explicitly is a +`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`, requesting them explicitly is a 400, and lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. @@ -594,7 +594,8 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire single read taken the instant send-and-wait returns comes back `""` even though the turn finished (verified live: empty on the first call, full text seconds later). `text` is also `""` before the worker's first completed turn, and always `""` for modes with -no transcript (`shell`, `opencode`, `gemini`, `antigravity`, verified live), which is +no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`; the first four +verified live, pi from the same source path), which is why the loop above is bounded rather than open-ended. Fall back to the terminal buffer there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive sessions; don't use it): @@ -678,7 +679,7 @@ turn), and both better than diffing terminal samples: ``` ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for -`opencode`/`codex`/`gemini`/`antigravity`** (those parsers are skipped wholesale) and +`opencode`/`codex`/`gemini`/`antigravity`/`pi`** (those parsers are skipped wholesale) and in practice empty for `shell`. Source-verified, not measured live. Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f8518adc..092a6403 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -237,7 +237,7 @@ minutes, never retry the credential. flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait returns is too early (verified live: empty on the first call, full prose seconds later). It is also `""` before the worker's first completed turn, and permanently `""` for -`shell`, `opencode`, `gemini` and `antigravity`, which write no transcript. +`shell`, `opencode`, `gemini`, `antigravity` and `pi`, which write no Claude transcript. **Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode, that is expected, not a failure: read `terminal?tail=` and strip ANSI instead. @@ -334,7 +334,7 @@ ESC=$(printf '\033') `POST /api/v1/quick-start` body (all optional): `{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` -, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity`; response is +, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. @@ -450,9 +450,9 @@ Quirks that will bite you: session answers with an empty timeline rather than a 404. - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` - returns early for every external CLI mode (`session.ts:2086`), so it is permanently - `[]` on `opencode`/`codex`/`gemini`/`antigravity`. ⚠️ **`shell` is NOT one of those** - (`isExternalCliMode`, `session.ts:164-166`, lists only those four), so the parser does + returns early for every external CLI mode (`session.ts:2136`), so it is permanently + `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`. ⚠️ **`shell` is NOT one of those** + (`isExternalCliMode`, `session.ts:165-167`, lists only those five), so the parser does run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:88`) matches bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper: a shell worker running `cat build.log` really does populate this. In practice it stays diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 5a882445..3edea7d2 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | liveness / death check | HTTP `wait?until=exit` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | -| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`) | HTTP only (no other CLI has messaging) | +| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`) | HTTP only (no other CLI has messaging) | | delete | HTTP, via SKILL.md's `delete_session` guard | ## Availability: probe, never assume @@ -345,7 +345,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw ### Mixed fleets: the pairing matrix -Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`) cannot be peers +Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`) cannot be peers at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention messaging in their briefs. The claude half of the fleet can use messaging among itself, subject to the namespace rule: **messaging works between two sessions that share one diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 9028a122..1c5e7d77 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -177,7 +177,7 @@ for _ in $(seq 1 10); do done printf '%s\n' "$TXT" # (.data is {text,timestamp}; text is also "" before the first completed turn and -# always "" for shell/opencode/gemini/antigravity, which have no transcript, use +# always "" for shell/opencode/gemini/antigravity/pi, which have no transcript, use # the terminal tail there, and here only to diagnose an unsubmitted prompt.) # 6. clean up: exact id, own list only, through the fail-closed preamble helper diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index f7054080..0d6e6047 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -7,6 +7,8 @@ * @module config/dependency-registry */ +import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js'; + export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; /** The valid `--category` filter values; single source of truth for the type, the CLI @@ -20,6 +22,13 @@ export interface PathResolver { bins: string[]; versionArg?: string; // default '--version' versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)? + /** + * Treat a binary whose version output does not match as NOT INSTALLED, instead of + * reporting it with an unknown version. Only for tools with a short, generic binary + * name (`pi`), where a `which` hit is not by itself evidence the right program is + * there and a false "installed" contradicts the run mode's own resolver. + */ + requireVersionMatch?: boolean; } /** Resolve a Windows-installed app reachable from win32 or WSL. */ @@ -112,7 +121,23 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ category: 'core', required: false, usedBy: ['Pi sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['pi'], versionArg: '--version' } }], + // The only entry that requires a version match, for the same reason + // pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling, + // personal scripts), so a `which pi` hit alone is not the coding agent. Both sides + // share PI_VERSION_REGEX, so the doctor and the run mode cannot drift into telling + // the user opposite things about the same binary. + resolvers: [ + { + match: ALL, + resolver: { + kind: 'path', + bins: ['pi'], + versionArg: '--version', + versionRegex: PI_VERSION_REGEX, + requireVersionMatch: true, + }, + }, + ], }, { id: 'libreoffice', diff --git a/src/session.ts b/src/session.ts index 801c1f84..17aac27c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -194,11 +194,20 @@ function getModeLabel(mode: SessionMode): string { * repaint via cursor positioning, so dropping the alt-screen switch is safe — * content stays in the normal buffer. Excluded: `shell` (arbitrary programs like * vim/less/htop legitimately need the alt screen), `opencode` (renders its own - * TUI that may rely on it) and `pi` (its default TUI already renders into the - * MAIN screen with terminal-owned scrollback, so there is nothing to strip — and - * since pi 0.84.0 the user can switch to a fullscreen TUI at runtime via - * `/settings`, where the alt screen is load-bearing). Keep parity with the - * replay-side strip in session-routes.ts. + * TUI that may rely on it) and `pi` (below). Keep parity with the replay-side + * strip in session-routes.ts. + * + * ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode + * falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen + * toggles too whenever the session is tmux-backed, and pi/opencode ALWAYS are + * (both refuse the direct-PTY fallback). What exclusion actually buys is the rest + * of the full strip: `\x1b[3J` and the mouse-tracking DECSETs survive. That is the + * real reason pi is out: its default TUI renders into the MAIN screen with + * terminal-owned scrollback and is mouse-aware, so it is a `3J`/mouse consumer in + * a way an Ink TUI repainting in place is not. Consequence to know before + * debugging it: pi's runtime-switchable fullscreen TUI (`/settings`, 0.84.0+) + * still gets its `?1049h` stripped and paints into the main buffer, exactly like + * vim inside a tmux `shell` session. */ export function isAltScreenStripMode(mode: SessionMode): boolean { return mode === 'codex' || mode === 'claude' || mode === 'gemini'; diff --git a/src/utils/dependency-checker.ts b/src/utils/dependency-checker.ts index beada7a9..6d5c6b4a 100644 --- a/src/utils/dependency-checker.ts +++ b/src/utils/dependency-checker.ts @@ -94,12 +94,17 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult { if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` }; if (spec.resolver.kind === 'path') { - const { bins, versionArg, versionRegex } = spec.resolver; + const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver; for (const bin of bins) { const resolved = host.which(bin); if (resolved) { const out = host.runVersion(bin, [versionArg ?? '--version']); const version = out ? extractVersion(out, versionRegex) : undefined; + // A generic binary name that prints the wrong thing is some OTHER program (see + // PathResolver.requireVersionMatch). Keep looking, then report MISSING; the + // alternative is claiming a tool is installed that the feature's own resolver + // rejects, which reads as "the mode is broken" rather than "install it". + if (requireVersionMatch && !version) continue; return finalize(base, tool, resolved, version); } } diff --git a/src/utils/pi-cli-resolver.ts b/src/utils/pi-cli-resolver.ts index a8145c0d..358fe835 100644 --- a/src/utils/pi-cli-resolver.ts +++ b/src/utils/pi-cli-resolver.ts @@ -30,8 +30,20 @@ const PI_SEARCH_DIRS = [ join(homedir(), 'bin'), ]; -/** A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`). */ -const PI_VERSION_PATTERN = /^\d+\.\d+\.\d+/; +/** + * A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`). + * + * Exported and SHARED with the `pi` entry in `config/dependency-registry.ts`, so + * `codeman doctor` and the run mode cannot disagree about what counts as an installed + * pi: two copies of this rule would let the Dependencies panel report "Pi CLI ✓" on a + * box where `resolvePiDir()` rejects the same binary and Run Pi stays hidden. + * + * Shape is dictated by the doctor's `extractVersion()`, which returns the first CAPTURE + * GROUP and scans the whole output: hence a capturing group, and a leading boundary + * instead of `^` so `pi 0.84.1` matches while `v0.84.1` (some other program) does not. + * No `g` flag, so there is no shared `lastIndex` to reset. + */ +export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/; /** Cached directory containing the pi binary (empty string = searched but not found) */ let _piDir: string | null = null; @@ -56,7 +68,7 @@ function probePiVersion(binPath: string): string | null { stdio: ['ignore', 'pipe', 'ignore'], }).trim(); // Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too. - const candidate = out.split(/\s+/).find((token) => PI_VERSION_PATTERN.test(token)); + const candidate = PI_VERSION_REGEX.exec(out)?.[1]; if (candidate) return candidate; console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" printed ${JSON.stringify(out.slice(0, 80))}`); } catch (err) { diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts new file mode 100644 index 00000000..de3a73e5 --- /dev/null +++ b/test/agent-skill-mode-lists.test.ts @@ -0,0 +1,103 @@ +/** + * @fileoverview Static guard: the packaged agent skill's run-mode enumerations stay in + * step with the modes the server actually accepts. + * + * `skills/codeman/**` is injected into cases and read by agents driving Codeman over + * HTTP, so a mode missing from its lists is not cosmetic: the agent is told a backend + * does not exist, or that a whole-class caveat ("these modes write no transcript") + * covers four modes when it covers five. Adding pi (#206) left every one of those lists + * stale while CI stayed green, because nothing tied the prose to the schema. + * + * Two rules, both derived from the RUNTIME source of truth (the Zod enum in schemas.ts, + * not a copy): + * + * 1. The `mode ∈ a|b|c` enumeration in endpoints.md is the mode list, exactly. + * 2. Any prose enumeration of 3+ distinct modes must be COMPLETE with respect to the + * external CLIs: those lists exist to describe what `isExternalCliMode()` gates + * (no Claude transcript, no hooks, no Claude-format parsers), so naming some but + * not all of them is the drift itself. Runs of one or two modes are exempt, since + * a legitimate pair ("claude or shell") is not a class claim. ONE exception is + * allowed and it is a real one: the "writes no transcript" lists drop `codex`, + * which does write a rollout Codeman reads back (the pane carries a unique + * originator precisely so `last-response` can find it), so external-minus-codex + * is a meaningful class rather than an oversight. + * + * Port: N/A (pure static analysis). + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { isExternalCliMode } from '../src/session.js'; +import type { SessionMode } from '../src/types/session.js'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const SKILL_DIR = join(HERE, '../skills/codeman'); +const SKILL_FILES = ['SKILL.md', 'reference/endpoints.md', 'reference/messaging.md', 'reference/recipes.md']; + +/** Modes the API actually accepts, read off the schema rather than restated here. */ +function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchema): SessionMode[] { + // `mode` is `z.enum([...]).optional()`; unwrap the optional to reach `.options`. + return (schema as unknown as { shape: { mode: { unwrap(): { options: SessionMode[] } } } }).shape.mode.unwrap() + .options; +} + +const MODES = schemaModes(CreateSessionSchema); +const EXTERNAL_MODES = MODES.filter(isExternalCliMode); + +/** + * Mode tokens appearing back to back, separated only by list punctuation — `a|b|c`, + * `a`/`b`/`c`, "`a`, `b` and `c`". Newlines collapse to spaces first so a wrapped list + * still reads as one run. The separator budget is deliberately small: it must span + * ", " and " and " without swallowing a sentence between two unrelated mentions. + */ +const MODE_ALTERNATION = MODES.map((m) => `\`?${m}\`?`).join('|'); +const ENUMERATION_RUN = new RegExp(`(?:(?:${MODE_ALTERNATION})(?:[\\s,/|]|and\\b|or\\b){0,6}){3,}`, 'g'); + +function enumerationRuns(text: string): string[] { + const flat = text.replace(/\s+/g, ' '); + return [...flat.matchAll(ENUMERATION_RUN)].map((m) => m[0]); +} + +function modesIn(run: string): SessionMode[] { + return MODES.filter((m) => new RegExp(`\\b${m}\\b`).test(run)); +} + +describe('agent skill run-mode lists', () => { + it('derives the mode list from the schema, and both endpoints agree', () => { + expect(MODES).toContain('pi'); + expect(new Set(schemaModes(QuickStartSchema))).toEqual(new Set(MODES)); + expect(EXTERNAL_MODES.length).toBeGreaterThan(1); + }); + + it("documents exactly the accepted modes in endpoints.md's `mode ∈ …` enumeration", () => { + const doc = readFileSync(join(SKILL_DIR, 'reference/endpoints.md'), 'utf-8'); + const match = doc.match(/`mode` ∈ `([a-z|]+)`/); + expect(match, 'endpoints.md no longer states the accepted `mode` values').not.toBeNull(); + expect(new Set(match![1].split('|'))).toEqual(new Set(MODES)); + }); + + it('never enumerates a partial set of external CLI modes', () => { + const complete = new Set(EXTERNAL_MODES); + /** The documented exception: codex writes a rollout, so it is absent from the + * "no transcript" lists on purpose. Every OTHER external mode must still be there. */ + const withoutCodex = new Set(EXTERNAL_MODES.filter((m) => m !== 'codex')); + const sameSet = (a: Set, b: Set) => a.size === b.size && [...a].every((v) => b.has(v)); + + const offenders: string[] = []; + for (const file of SKILL_FILES) { + for (const run of enumerationRuns(readFileSync(join(SKILL_DIR, file), 'utf-8'))) { + const listed = modesIn(run); + if (listed.length < 3) continue; + const externals = new Set(listed.filter(isExternalCliMode)); + // Empty is fine (a claude/shell-only list); partial is the drift. + if (externals.size === 0 || sameSet(externals, complete) || sameSet(externals, withoutCodex)) continue; + const missing = EXTERNAL_MODES.filter((m) => !externals.has(m)); + offenders.push(`${file}: "${run.trim()}" is missing ${missing.join(', ')}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index b3bb5573..13b7f0b5 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -10,6 +10,7 @@ import { } from '../src/utils/dependency-checker.js'; import type { ProbeHost } from '../src/utils/dependency-checker.js'; import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js'; +import { PI_VERSION_REGEX } from '../src/utils/pi-cli-resolver.js'; describe('DEPENDENCY_REGISTRY', () => { it('has unique ids', () => { @@ -29,6 +30,20 @@ describe('DEPENDENCY_REGISTRY', () => { expect(office.every((t) => t.required === false)).toBe(true); }); + it('resolves pi through the SAME version rule the run mode uses', () => { + // `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not + // print semver. If the doctor did not apply the identical rule it would report + // "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode + // rather than a missing install. One regex, shared, is what keeps them agreeing. + const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi'); + expect(pi).toBeDefined(); + const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path'); + expect(spec).toBeDefined(); + const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean }; + expect(resolver.requireVersionMatch).toBe(true); + expect(resolver.versionRegex).toBe(PI_VERSION_REGEX); + }); + it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => { const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice'); expect(ms).toBeDefined(); @@ -164,6 +179,54 @@ describe('checkTool', () => { }); }); +describe('checkTool with requireVersionMatch (generic binary names)', () => { + const piTool: ToolDependency = { + id: 'pi', + label: 'Pi CLI', + category: 'core', + required: false, + resolvers: [ + { + match: ['linux'], + resolver: { + kind: 'path', + bins: ['pi'], + versionArg: '--version', + versionRegex: PI_VERSION_REGEX, + requireVersionMatch: true, + }, + }, + ], + }; + + it('accepts a binary that prints a semver version', () => { + const host = fakeHost('linux', { which: () => '/home/u/.npm-global/bin/pi', runVersion: () => '0.84.1\n' }); + expect(checkTool(piTool, host)).toMatchObject({ + id: 'pi', + status: 'ok', + version: '0.84.1', + path: '/home/u/.npm-global/bin/pi', + }); + }); + + it('reports MISSING for an unrelated `pi` on PATH instead of an installed tool', () => { + // The whole point: a Raspberry Pi helper answers `--version` with prose, and calling + // that "installed" contradicts resolvePiDir(), which rejects it. + const host = fakeHost('linux', { which: () => '/usr/bin/pi', runVersion: () => 'Raspberry Pi utility\n' }); + expect(checkTool(piTool, host)).toMatchObject({ id: 'pi', status: 'missing' }); + }); + + it('reports MISSING when the binary answers nothing at all', () => { + const host = fakeHost('linux', { which: () => '/usr/bin/pi', runVersion: () => null }); + expect(checkTool(piTool, host)).toMatchObject({ id: 'pi', status: 'missing' }); + }); + + it('leaves tools without the flag reporting ok on an unparsable version (unchanged)', () => { + const host = fakeHost('linux', { which: () => '/usr/bin/tmux', runVersion: () => 'no version here' }); + expect(checkTool(tmuxTool, host)).toMatchObject({ id: 'tmux', status: 'ok', version: undefined }); + }); +}); + describe('checkAll', () => { it('maps every tool to a result', () => { const results = checkAll([tmuxTool, msTool], fakeHost('linux')); From 86234db1ef542ccc699527e110c048bc49e2aef2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 13 Aug 2026 17:55:43 +0200 Subject: [PATCH 4/4] docs(skill): document the per-CLI availability probes, and guard the family `GET /api/pi/status` shipped undocumented in the agent skill, and only a human reading the doc noticed. Turns out none of its five siblings were documented either, so this adds the whole family in one place: spawning with a mode whose CLI is absent fails with OPERATION_FAILED rather than falling back, which is exactly what an agent picking a backend it did not choose needs to know. Pi's extra `.data.version` is called out, since a false `available:false` there means an unrelated `pi` is in front on PATH. On whether the endpoint scanner should also check registered-to-documented: measured, and NO for the general case. The skill documents 34 of 217 registered endpoints deliberately (it is an agent guide, not an API reference), so a blanket reverse check needs a 183-entry allowlist that would fail CI on unrelated route work and get appended to mechanically, which is worse than the gap it closes. Grouping by path shape does not save it either: the families that yields are things like `DELETE /api//:id`, lumping cases, webviews and docker hosts together, and it would not have caught this gap anyway (the family had zero documented members). What IS cheap is a family the schema can enumerate with no allowlist: the new assertion derives the agent modes from the Zod enum and requires each one's `/api//status` to be documented, so a seventh backend fails here until it is. The sibling scanner still proves the other direction, that nothing documented is a 404. Both mutation-checked: dropping pi's probe fails the new guard, and documenting a nonexistent probe fails the old one. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/ba4bc996.md | 2 +- skills/codeman/reference/endpoints.md | 10 ++++++++++ test/agent-skill-mode-lists.test.ts | 24 +++++++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.changeset/ba4bc996.md b/.changeset/ba4bc996.md index ef3f1819..223046b1 100644 --- a/.changeset/ba4bc996.md +++ b/.changeset/ba4bc996.md @@ -13,6 +13,6 @@ Add Pi (pi.dev) as a sixth CLI run mode (#206). - **Pi stays out of `isAltScreenStripMode()`.** Its default TUI renders into the main screen with terminal-owned scrollback and is mouse-aware, so it consumes `\x1b[3J` and the mouse DECSETs that the full strip removes, unlike an Ink TUI repainting in place. Note what exclusion does NOT do: pi is tmux-backed, so it still falls through to the narrow `isMuxAltScreenOnlyStripMode()` strip and its alt-screen toggles are dropped either way. Pi's runtime-switchable fullscreen TUI therefore paints into the main buffer, exactly like vim inside a tmux `shell` session. - **Docker**: pi installs in its own `--ignore-scripts` step so that flag cannot affect the other four CLIs, and its credentials are seeded per-file (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json`) rather than whole-dir, since `~/.pi/agent` also holds sessions, extensions and installed package trees. - **Local echo**: pi lands on the buffer overlay. Verified that codex's per-keystroke starvation does not reproduce — pi's slash picker re-filters on the whole composer content, so a one-shot flush behaves identically to per-keystroke typing. -- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. The packaged agent skill's mode enumerations list pi too, pinned by a new guard that derives the mode set from the Zod schema instead of restating it. +- **Mode-list parity**: pi is excluded from the Ralph tracker auto-enable on `POST /api/sessions/:id/interactive` (like every other external CLI, whose output the tracker never parses), carries a `REMOTE_CLI_BIN` entry so a remote-SSH pi session reports its CLI version, and gets its own badge in the desktop home rail instead of rendering like Claude. The packaged agent skill's mode enumerations list pi too, and it now documents the per-CLI availability probes (`GET /api//status`) that agents should check before spawning a worker on a backend the server may not have installed. Both are pinned by a new guard that derives the mode set from the Zod schema instead of restating it. - **`codeman doctor` and the run mode agree about pi.** The registry entry resolved a bare `which pi` while `pi-cli-resolver` demanded semver output, so the Dependencies panel could report an installed Pi CLI that sessions refuse to launch. Both now share one exported regex, and the registry's new `requireVersionMatch` reports a non-semver `pi` as missing rather than installed. Only pi sets it; every other tool keeps its existing behaviour. - Installer detection, docs (`docs/pi-integration.md`), READMEs, and the architecture invariants are updated. Tests: `test/pi-mode.test.ts` and `test/routes/external-cli-bypass-clamp.test.ts`, plus extensions to the run-mode, mobile-overview, render-index-html, system-routes and local-echo suites. diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 092a6403..9fb93d71 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -338,6 +338,16 @@ ESC=$(printf '\033') `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. +⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with +`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick +the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, +`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status` +and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed). +Pi's also carries `.data.version`, because `pi` is a short generic name that an unrelated +binary on `$PATH` can shadow: the resolver rejects one whose `--version` is not +semver-shaped, so `available:false` there can mean "a different `pi` is in front" rather +than "nothing is installed". `shell` has no CLI to probe. + ⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts index de3a73e5..5092a506 100644 --- a/test/agent-skill-mode-lists.test.ts +++ b/test/agent-skill-mode-lists.test.ts @@ -11,7 +11,17 @@ * Two rules, both derived from the RUNTIME source of truth (the Zod enum in schemas.ts, * not a copy): * - * 1. The `mode ∈ a|b|c` enumeration in endpoints.md is the mode list, exactly. + * 1. The `mode ∈ a|b|c` enumeration in endpoints.md is the mode list, exactly, and the + * per-CLI availability probe (`GET /api//status`) is documented for every + * agent mode. That second half is the narrow, family-scoped answer to "should the + * endpoint scanner also check registered-to-documented?". In general it should not: + * the skill documents 34 of 217 registered endpoints on purpose (it is an agent + * guide, not an API reference), so a blanket reverse check needs a 183-entry + * allowlist that fails CI on unrelated routes and gets appended to mechanically. + * Grouping by path shape does not rescue it either: the families that produces are + * things like `DELETE /api//:id`, which lumps cases, webviews and docker hosts + * together. A family the SCHEMA can enumerate is the exception, since it needs no + * allowlist at all. * 2. Any prose enumeration of 3+ distinct modes must be COMPLETE with respect to the * external CLIs: those lists exist to describe what `isExternalCliMode()` gates * (no Claude transcript, no hooks, no Claude-format parsers), so naming some but @@ -72,6 +82,18 @@ describe('agent skill run-mode lists', () => { expect(EXTERNAL_MODES.length).toBeGreaterThan(1); }); + it('documents the CLI availability probe for every agent mode', () => { + // The gap this closes: /api/pi/status shipped undocumented and only a human reading + // the doc noticed, because the sibling scanner (agent-skill-endpoints-doc.test.ts) + // only checks documented -> registered. Derived from the schema, so a seventh + // backend fails here until its probe is documented; the sibling test still proves + // the reverse, that nothing documented here is a 404. + const doc = readFileSync(join(SKILL_DIR, 'reference/endpoints.md'), 'utf-8'); + const documented = new Set([...doc.matchAll(/\bGET\s+\/api(?:\/v1)?\/([a-z-]+)\/status\b/g)].map((m) => m[1])); + const probeable = MODES.filter((m) => m !== 'shell'); // shell has no CLI to probe + expect([...probeable].filter((m) => !documented.has(m))).toEqual([]); + }); + it("documents exactly the accepted modes in endpoints.md's `mode ∈ …` enumeration", () => { const doc = readFileSync(join(SKILL_DIR, 'reference/endpoints.md'), 'utf-8'); const match = doc.match(/`mode` ∈ `([a-z|]+)`/);