From c5b59633d8a67985fe17dc10eba033659d65301e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 13 Aug 2026 13:54:47 +0200 Subject: [PATCH] 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'); + }); +});