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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-13 13:54:47 +02:00
parent f39beb3326
commit c5b59633d8
45 changed files with 2143 additions and 101 deletions
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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. |
+5 -3
View File
@@ -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"
+681
View File
@@ -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/--<cwd with / replaced by ->--/<timestamp>_<uuid>.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 <socket> 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<SessionMode, '...'>` 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 `:<thinking>` 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 <v>` | `/^[a-zA-Z0-9._\-/:]+$/` (`:` for `sonnet:high`, `/` for `openai/gpt-4o`) |
| `provider` | `--provider <v>` | `/^[a-z0-9-]+$/` |
| `thinking` | `--thinking <v>` | runtime allowlist of the 7 enum values (defense in depth beyond Zod) |
| `resumeSessionId` | `--session <v>` | `/^[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 <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 <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 `<select>`
generically and special-cases only `shell`).
| File | Change |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| `index.html` | Welcome button `welcomePiBtn` after Gemini's (antigravity's is `:347`; there is deliberately no codex welcome button), `display:none` default, `onclick="app.setRunMode('pi'); app.runPi()"`, text `Run Pi`; run-mode-option row with `.run-mode-dot.pi` after antigravity's (`:526-528`), before the `.run-mode-sep` `:529`; cron `<option value="pi">Pi</option>` after `:803`; **NEW: the clone-repo "Brain" picker** (`cloneCaseBrain`, `:2476-2486`): add `<option value="pi" data-cli="pi">Pi</option>` after the antigravity option `:2483` (gating is automatic: session-ui.js `:2107-2115` hides options whose `data-cli` fails `isCliAvailable`, and `:2250` reads the value at clone time); docker image hint `:2624` (`claude/codex/gemini/opencode/agy` + pi). No per-CLI remote-command override field needed (only codex has one, `:2559`) |
| `session-ui.js` | `@fileoverview` mode list `:2`; `run()` dispatch branch after `:400-402`; `_refreshRunModeAvailability` list `:468` (+`'pi'` as a quoted literal, the static test in §6 demands it); short-label ternary `:565` (+`'Run PI'`); **the `runMode` setter whitelist `:2949-2960`** (§2.8, the deceptive one); new `runPi()` modeled on `runAntigravity()` `:1170-1219`: same remote/docker skip, same `_beginSessionLaunchStatus` frame, probes `/api/pi/status` reading `(await res.json()).data.available` (envelope!), **sends no `piConfig` at all** (no bypass exists and trust defaults are pi's own; envOverrides still sent for local cases), install-hint error text matching Phase 1's; `isAltMode` `:1233` and `isExternalCli` `:1263` four-way comparisons (+pi) |
| `settings-ui.js` | `applyWelcomeCliVisibility()` `:1176-1191`: add `['welcomePiBtn', 'pi']` |
| `app.js` | Response-viewer agent label `:1998-2009` (+pi -> `'Pi'`); tab badge ternary `:3884` (`<span class="tab-mode pi" aria-hidden="true">pi</span>`; claude stays badge-less); kill-title ternary `:5046-5057` (`Kill Tmux & Pi`) |
| `panels-ui.js` | Command-palette `labels` map `:430` (+`pi: 'Pi'`; the `\|\| mode` fallback means this is cosmetic, not load-bearing) |
| `mobile-overview.js`| `MOBILE_OVERVIEW_RUN_MODES` `:55-62`: `{ mode: 'pi', label: 'Pi', short: 'Pi' }` after antigravity `:60`, before the shell entry. Nothing else: the Run-button badge (`:499`) and menu builder (`:554-556`) consume the list generically, and the buttons carry `btn-toolbar btn-run mode-pi`, which is exactly why they inherit the §2.9 cascade problem and its fix |
| `terminal-ui.js` | Badge-row comment `:1750` only (the badge itself is a raw `s.mode` passthrough, no list to extend). `_sessionUsesServerMouseStrip` unchanged (§2.2). `_updateLocalEchoState` unchanged for v1 (§2.10: pi lands on `'buffer'` via the fallthrough; only touch it if E2E forces the `'off'` fallback) |
| `i18n.js` | `'Run Pi': '运行 Pi'` in the zh-CN table (`:102-107`, matches the welcome-button text; short labels like `Run PI` are deliberately untranslated, as are the other modes') |
| `styles.css` | Tab badge `.session-tab .tab-mode.pi` after `:2157` (`background: rgba(244,114,182,0.2); color: #f472b6;`); add `.session-tab .tab-mode.pi` to the light-skin ink list `:325-336` (gemini + antigravity are its precedent, `:332`); welcome `.welcome-btn-pi` + `:hover` after antigravity's `:3366` block, rose family (e.g. base `linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%)`, border `rgba(244,114,182,0.4)`, text `#fce7f3`); toolbar gradient pair `.btn-toolbar.btn-run.mode-pi, .btn-toolbar.btn-run-gear.mode-pi` + `:hover` after `:4420`'s antigravity block; `.run-mode-dot.pi { background: #f472b6; }` in the dot list `:4506-4516`; **and the §2.9 rule inside the Daylight block** next to codex's `:13787` (e.g. `background: linear-gradient(135deg, #be185d, #f472b6); border-color: #be185d; color: #fff1f7;`). The dot needs no skin-block entry (the block overrides only claude/opencode/codex/shell dots; gemini/antigravity dots already fall through correctly) |
| `mobile.css` | Phone toolbar block after `:910` inside the `@media (max-width: 430px)` opened at `:338`: `mode-pi` base + `:active`, **with `!important` on background/border-color/color** (§2.9; antigravity's block `:895-910` omits it and is dead); light-skin override entry after `:2985` with the same four-skin `html:is(...)` prefix as its siblings |
### Phase 4: Docker image and installer
Both files are unchanged since the 2026-08-06 verification; all anchors stand.
- `docker/agent.Dockerfile`: a **separate** `RUN` step after the antigravity block (`:38-45`), not a
fifth line in the shared npm block (`:31-36`), because pi documents `--ignore-scripts` and that
flag must not silently change how the other four install:
```dockerfile
# Pi (pi.dev). Upstream documents --ignore-scripts (pi needs no lifecycle scripts);
# kept out of the shared npm block above so the flag cannot affect the other CLIs.
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \
&& npm cache clean --force \
&& pi --version
```
Implementation checklist item: the gid-0 pre-created dirs at `:64-68` include `.claude/projects`
and `.codex/sessions`; verify whether the cred-seed copy into `~/.pi/agent` creates its target
dir in a fresh container or whether `.pi/agent` must join that `mkdir` line. Rebuild with
`node scripts/build-agent-image.mjs --no-cache` (the script itself needs no change; nothing in it
is CLI-specific). The cached npm layer has silently frozen a CLI at a broken version before; see
`docs/docker-cases.md`.
- `install.sh` (six edit sites, all verified): `PI_SEARCH_PATHS` block after `:125` (mirror the
resolver's dirs); `check_pi` / `get_pi_path` pair inserted at `:531` (antigravity's pair spans
`:504-530`); the satisfying-AI-CLI chain `:2032-2063` (`has_pi` local at `:2037` area, detect
block after `:2059`, widen the five-way test at `:2061` and the warn text at `:2063`); the menu
option-4 text `:2070`; the skip-path hints `:2115-2116` (add
`npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)`); the final no-CLI
reminder `:2416-2423` (add `check_pi` to the condition and a pi line to the echo block).
Detection plus a hint only; do **not** add an auto-install path in this change.
### Phase 5: Docs
- `docs/pi-integration.md` (**new**, user-facing): install (both installers uninstall via npm), auth
(`/login` OAuth for six providers vs API keys; `pi auth check` for preflight; Claude Pro/Max
third-party harness usage bills as Anthropic "extra usage" per token, not plan limits; OpenRouter
login supports pasting the redirect URL, which matters over remote SSH), what Codeman wires up
and deliberately does not (§3, incl. never passing `--tui-mode`), the tmux extended-keys note
from §2.7 with the manual `~/.tmux.conf` fallback, Docker/remote behaviour (in-container sessions
invisible host-side), the trust model in §1 words, known gaps.
- `CLAUDE.md`: tech-stack line (six CLIs + `SessionMode` union), the env-prefix gotcha bullet, the
multi-CLI prefix-discipline bullet, the "External CLI modes" key-pattern paragraph (note it now
also carries the codex predictive-echo block; pi's echo-policy decision from §2.10 belongs in the
same paragraph), the `src/utils/` resolver list.
- `docs/architecture-invariants.md`: the external-CLI-modes section. ⚠️ Its anchor was already
renamed once to `#external-cli-modes-opencode-codex-gemini-antigravity` while CLAUDE.md's link
text still shows the old name; when renaming again for pi, update every inbound link (CLAUDE.md
and this file).
- `docs/docker-cases.md` (cred-seeding table + supported modes + image contents),
`docs/remote-sessions.md` (`RemoteCommandMode`), `docs/cron-guide.md` + `docs/cron-discovery.md`
(`agentType` enum; note the readiness caveat from §6's cron paragraph),
`docs/security-architecture.md` (env prefix allowlist row).
- `README.md` + `README.zh-CN.md`: six CLIs.
- `package.json` keywords: `pi`.
- Update the issue #206 thread when it ships.
---
## 5. Security checklist
1. **Command injection.** Every `PiConfig` value is regex-validated in `buildPiCommand()` before
entering the `bash -c "..."` string; anything failing validation is dropped, not escaped
(matching the four existing builders). No user string reaches the spawn line unvalidated. Pinned
by a "rejects unsafe values" test per field.
2. **Multi-user clamp, materialize branch.** `approveProjectTrust` is the privilege-shaped field: it
makes pi execute repository-supplied TypeScript and install project packages.
`clampExternalCliBypassForOwner()` (`session-routes.ts:305-336`) has two branches, and pi belongs
in the **gemini-style materialize branch**, not the codex/antigravity only-if-sent branch:
pi's absent-config default is an *interactive trust prompt the session user can answer
themselves in the terminal*, so merely omitting `--approve` is not a clamp. For a non-granted
owner, materialize `{ ...(piConfig ?? {}), approveProjectTrust: false }` so `buildPiCommand`
always emits `--no-approve` and the prompt never appears. Both call sites (`:845`, `:2897`)
widen. This helper still has **zero test coverage** (re-confirmed at f39beb3); §6 adds the first
tests.
3. **Secrets stay off the command line.** `PI_*` overrides flow through `applyEnvOverrides()` /
socket-scoped `tmux setenv`, never inlined into the spawn string. No `-e` at container create
time. And `--api-key` is never wired (§3): it would put a provider secret into `ps`/tmux state.
4. **Env allowlist not widened.** Only the `PI_` prefix is added; the provider keys stay out (§2.4)
and `ALLOWED_ENV_KEYS` is untouched. Pinned by a test that `PI_OFFLINE` passes and
`ANTHROPIC_API_KEY` still fails validation.
5. **Docker seeding, not sharing.** Per §2.5: RO mount then copy, so refreshed OAuth tokens never
write back to the host; bind mounts stay excluded from `docker commit` so exports remain
secret-free.
6. **Remote SSH.** `pi` mode goes through `defaultRemoteCommandForMode` and therefore
`buildSshConnectionArgs()`. No hand-built ssh line anywhere.
7. **No sandbox claims.** Pi documents that it has no sandbox and no permission prompts, and that
extensions run with the user's full permissions. Codeman docs must say plainly that a pi session
can read, write and execute anything the Codeman user can, and point at Docker cases as the
isolation story. Do not imply the trust prompt is a safety boundary (upstream itself says it is
not). Worth one doc sentence: `pi auth print-api-key` / `print-bearer-token` (0.83.0) and
`pi auth check` (0.84.1) mean a pi session can print its own provider credentials by design;
isolation, again, is Docker.
8. **Loud-vs-silent audit.** Before review, walk §2.8's silent list and confirm each site has its
pi branch; the loud ones the compiler already caught.
---
## 6. Test plan
- `test/pi-mode.test.ts` (**new**, modeled on `test/antigravity-mode.test.ts`, 125 lines, no port;
file unchanged since 2026-08-06 so its structure remains the template):
`CreateSessionSchema`/`QuickStartSchema` accept a pi config; unsafe `model`/`provider`/
`resumeSessionId` values are rejected (`'pi; rm -rf /'` shapes); `buildSpawnCommand({ mode: 'pi', ... })`
emits expected flags, drops invalid ones, emits `--no-approve` for `approveProjectTrust: false`
and `--approve` for `true`, and skips `-c` when a `resumeSessionId` is present;
`defaultDockerCommandForMode('pi') === 'exec pi'` and
`defaultRemoteCommandForMode('pi') === 'exec "${SHELL:-/bin/sh}" -i -l -c \'pi\''`;
`isExternalCliMode('pi') === true`, `isAltScreenStripMode('pi') === false`; the env pair
(`PI_OFFLINE` accepted, `ANTHROPIC_API_KEY` rejected), mirroring antigravity-mode `:49-63`.
- **First-ever coverage for `clampExternalCliBypassForOwner`** (still nothing in `test/` touches
it): cover pi's materialize branch (absent config still yields `approveProjectTrust: false` for a
non-granted owner; a sent `true` is forced to `false`; granted owner passes through) and, while
there, pin the three existing modes' behavior. Prefer exporting the helper for direct unit tests
over a heavier multi-user route fixture; either way it lives under `test/routes/`.
- `test/run-mode-ui.test.ts`: extend `loadUi()`'s stub lists (welcome-button ids, mode buttons,
`ALL_OFF`) and add pi welcome/dropdown gating cases; note the static parser test
`'gates every mode the run-mode menu actually offers'` (`:433-456`) picks up the new
`data-mode="pi"` from index.html automatically and **fails until** `_refreshRunModeAvailability`
contains a quoted `'pi'`, which is exactly the regression it exists for. Add a
`describe('Pi quick start')` modeled on the antigravity one (`:840`) driving `runPi()` against a
stubbed `/api/pi/status` + `/api/quick-start`, asserting the posted body has `mode: 'pi'` and
**no `piConfig`**, and that the envelope is unwrapped. (The short-label assertion pattern is at
`:82`, `'Run AG'`.)
- `test/render-index-html.test.ts` `:141`: the injected `window.__codemanCliAvailable` is asserted
with an exact `toEqual` and now carries **seven** keys (claude, opencode, codex, gemini,
antigravity, cloudflared, and since 1.12+ `git`), so it **must** gain the `pi` key (and the
resolver mock an `isPiAvailable`); its comment explains why: a dropped key silently un-gates
(§2.8).
- `test/routes/system-routes.test.ts`: `GET /api/pi/status` shape, modeled on the antigravity
describe (`:816-838`) + resolver mock (`:84-87`); file unchanged since 2026-08-06.
- `test/mobile-overview.test.ts`: `:375` is an exact-array `toEqual` over the run-menu modes and
**will fail until updated** to include `'pi'` (the second exact-array at `:366`,
`['claude', 'shell']`, is a gating case and stays as-is); the sibling static parser then covers
the new entry automatically. The no-hex-literals guard only scans `.mobile-overview*` rules, so
pi's `mode-pi` colors in mobile.css do not trip it.
- `test/local-echo-codex-gating.test.ts` (§2.10): once the buffer-policy decision is confirmed in
E2E, add `'pi'` to the `it.each(['claude', 'gemini', 'opencode'])` lists (`:193`, `:376`) so the
chosen policy is pinned.
- `test/skin-themes.test.ts`: will NOT trip (it enumerates skins, not modes); run it anyway since
styles.css is touched. `test/mobile-header-buttons-policy.test.ts`: trips only if a header
button is added; pi adds none (welcome button and run-menu rows are outside `header-right`).
- Cron: schema-level acceptance of `agentType: 'pi'` (the service consumes `SessionMode`
generically; `src/cron/` is unchanged since the first draft). Known, documented degradation: the
readiness poll (`cron-service.ts:515`) looks for `❯`/`tokens`, which pi never prints, so cron pi
jobs burn the ready-poll attempts and then send anyway. Acceptable for v1; note it in
`docs/cron-guide.md`.
- Sweep with `npm run test:ci`. Never bare `npm test`. No new ports needed (all new/extended suites
are portless).
---
## 7. End-to-end verification (required before COM)
Unit tests passing is not evidence the mode works (pi is not currently installed on the dev box, so
step 1 is a real step). Before shipping:
1. Install pi (`npm install -g --ignore-scripts @earendil-works/pi-coding-agent`), authenticate once
with `/login`.
2. `curl -sk https://localhost:3000/api/pi/status | jq` reports `available: true`, the right path,
and a sane `version`.
3. Create a **throwaway** case, launch a pi session from the Run dropdown, send a prompt from the
browser, confirm the reply renders and scrollback survives a tab switch. Do not touch
`w1`/`w2`/`w3`.
4. **Local-echo policy gate (§2.10):** on a phone profile, type into the pi editor through the
buffer overlay (drive with `page.keyboard.type()`, never `app.sendInput()`, and force
`app._localEchoEnabled = true`; headless Chromium reports touch as false) and confirm pi's
composer renders the flushed text correctly on Enter. If it mis-renders, flip pi to the `'off'`
branch in `_updateLocalEchoState` and pin that instead.
5. Visual pass on the **default skin** (the §2.9 finding makes this the load-bearing check, not a
formality): run-button gradient actually renders rose (not generic claude blue), dot, tab badge,
welcome button, kill-menu label; then a phone profile (toolbar `!important` colors and light-skin
overrides are the usual regressions).
6. Kill and respawn the session; confirm `piConfig` round-trips through `state.json` and the pane
comes back with the same flags. Then `/clear`-style respawn via the Respawn tab.
7. Extended keys (§2.7): in an attached terminal, verify whether Shift+Enter inserts a newline in
pi's editor with and without the socket-scoped options; record the outcome in
`docs/pi-integration.md` either way. While attached, also flip `/settings` to the fullscreen TUI
and back to confirm the no-strip decision holds (§2.2).
8. Trust model: point a throwaway case at a repo containing `.pi/extensions`, confirm the trust
prompt appears interactively and that a multi-user non-granted session instead launches with
`--no-approve` (prompt never shown, extensions not loaded).
9. **NOT RUN in this pass — an honest gap.** Docker case with `mode: 'pi'`: rebuild the agent image with `--no-cache`, confirm `pi --version`
inside the container **as the `agent` user**, confirm seeded auth works and a session starts
(this is exactly where the antigravity Docker path broke in 1.11.2: the CLI was never installed
in the image).
10. **NOT RUN in this pass — the other gap.** Remote SSH case with `mode: 'pi'`: confirm the
login-shell wrapper resolves the npm global bin.
11. Only then: changeset, `COM minor` (new capability, additive to the API surface).
**Verification actually performed** (2026-08-13, pi 0.84.1, isolated `CODEMAN_INSTANCE=pi-beta`
server on :5055 with its own tmux socket and data dir): steps 1-8 pass. Highlights:
`/api/pi/status` resolved through the **search-dir fallback** (pi installed to `~/.npm-global/bin`,
deliberately not on PATH) and reported
`{available:true, path:'/home/arkon/.npm-global/bin', version:'0.84.1'}`; the real spawn line came
out as `… COLORTERM=truecolor … && pi --approve --provider anthropic --thinking high`; `piConfig`
round-tripped through `state.json` across a **full server restart**; the trust prompt appeared for a
case containing `.pi/extensions` + `.pi/settings.json`, and `--no-approve` suppressed it
(`This project is not trusted. Project .pi resources and packages are ignored.`); on the **default
`daylight-blue` skin** the toolbar Run button computed to
`linear-gradient(135deg, rgb(190,24,93), rgb(244,114,182))` — genuinely rose and **distinct from
claude's blue**, so the §2.9 cascade trap is avoided; and flipping `/settings` to the fullscreen TUI
put the pane into the alt screen (`alternate_on=1`), **empirically confirming §2.2**: had pi been in
the strip list, Codeman would have stripped that switch and corrupted the session. Steps 9-10 need a
Docker daemon and a remote host respectively.
---
## 8. Effort estimate
Calibrated against the real antigravity history, which is the honest baseline: the feature commit
`26cbbe0` was 24 files, +638/-63, and it then took **four follow-up commits** (`e803186` login-shell
routing, `292ba2c` ownership helpers, `5d28999` CLI gating incl. tests, `0d0b772` docs/installer/UI
propagation) totaling roughly +600/-170 across ~43 file-touches to make the mode actually
first-class. Budgeting only the feature-commit shape under-scopes by ~40%. This plan folds all four
follow-up surfaces in from the start (login-shell routing in Phase 1, availability gating in Phases
2-3, installer/docs propagation in Phases 4-5), so expect the full footprint in one pass:
| Phase | Size |
| --------------------- | -------------------------------------------------------------------------- |
| 1. Backend core | ~260 lines across 9 files, one new file (resolver incl. version probe) |
| 2. Web layer | ~110 lines across 4 files (incl. the clamp widening + availability inject) |
| 3. Frontend | ~175 lines across 10 files (enumerations + CSS in two sheets + skin block + the Brain picker option) |
| 4. Docker + installer | ~45 lines, plus one `--no-cache` image rebuild |
| 5. Docs | one new doc, ~10 files touched |
| 6. Tests | one new test file, 6 extended (2 of which fail loudly until updated), plus the first clamp coverage |
---
## 9. Out of scope, tracked as follow-ups
- **A Codeman pi extension for real idle/completion events (highest value, now fully de-risked).**
Pi extensions are TypeScript modules with Node built-ins and npm deps available, so an HTTP POST
to `/api/hook-event` is trivial. The **`agent_settled`** event **shipped in 0.84.0** and is
documented for exactly this use case (fires only when pi will not continue on its own: after
auto-retries, auto-compaction and queued follow-ups; `ctx.isIdle()` is true inside the handler).
That is a genuine idle signal replacing output-silence heuristics, i.e. the same class of upgrade
hooks give Claude sessions. The bash tool exposes five env vars (`PI_SESSION_ID`,
`PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, `PI_REASONING_LEVEL`), injected per command. Bonus:
an extension can own the **`project_trust`** event (first yes/no wins, and CLI `-e` extensions
load *before* trust resolution), so Codeman could answer the trust prompt programmatically, a
cleaner mechanism than the `--approve` flag for both the single-user convenience case and the
multi-user deny case.
- **Response viewer for pi.** Sessions are JSONL v3 under
`~/.pi/agent/sessions/--<cwd-dashed>--/<timestamp>_<uuid>.jsonl` with an `id`/`parentId` tree and
typed content blocks (text, image, thinking, toolCall); the cwd-derived dir name is trivially
computable host-side. Feasible, and it would justify flipping the Docker cred policy to share
`sessions/` RW like Codex.
- **Mode-aware env allowlist.** Would let pi sessions accept provider keys without widening the
global list. Needs `ALLOWED_ENV_PREFIXES` to become a per-mode map plus mode context inside the
Zod refine.
- **`--tools` / `--exclude-tools` / `--no-tools` / `--no-builtin-tools` read-only sessions** (plus
the 0.84.0 `defaultTools` setting). Real product value, needs UI.
- **Predictive echo for pi's composer** if the §2.10 buffer decision does not hold up in practice:
teach `PredictiveEchoAddon` pi's composer row the way `isCodexComposerRow` handles codex's.
- **`--mode json` / `--mode rpc`, and upstream's experimental remote-session client APIs**
(transport-neutral `PiClient`, CBOR protocol, Unix-socket transport, `RemoteSession` controller,
still unreleased as of 0.84.1). A potential non-PTY integration path, a different architecture
from the tmux+PTY model. Note the already-shipped breaking change to `message_update` framing
(delta-only): any consumer must assemble deltas between `message_start`/`message_end`.
- **`--name` for session labels.** Blocked on shell-quoting a user string in `buildSpawnCommand`.
---
## 10. Risks
| Risk | Mitigation |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `pi` resolves to an unrelated binary | `pi --version` + semver-shape check in the resolver (§2.6); path and version shown in `/api/pi/status` |
| Pi's TUI repaints in a way the browser terminal handles badly | Test scrollback and repaint early (step 3 of §7); pi's default is main-screen with terminal-owned scrollback, which is the friendly case |
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly |
| The buffer local-echo overlay fights pi's live composer | §2.10: explicit E2E gate (§7 step 4) with the one-line `'off'` fallback; predictive echo for pi is a tracked follow-up, not a v1 blocker |
| Pi moves fast (pre-1.0; 9 releases in the 7 weeks before 0.84.1) | Keep the flag surface small; every flag validated and droppable; nothing pinned in the Dockerfile beyond the `--no-cache` rebuild cadence. Live example of the hazard: `--tui-mode` went from main-only docs to released between the two drafts of this plan |
| Docker image grows | Pi is an npm package; the layer is modest next to the ~190MB `agy` binary |
| Trust prompt blocks a session | Narrower than feared: only fires when `.pi/settings.json`, `.pi/extensions\|skills\|prompts\|themes`, `.pi/SYSTEM.md`/`APPEND_SYSTEM.md` or `.agents/skills` exists (bare `.pi/` does not). Documented; `approveProjectTrust` is the opt-in escape hatch; multi-user forces `--no-approve` (§5.2); the `project_trust` extension follow-up removes the prompt entirely |
| Interactive `/login` OAuth can't complete headlessly | Document: authenticate once interactively (or seed `auth.json`); `pi auth check` verifies credentials preflight; OpenRouter's paste-the-redirect-URL flow covers remote SSH |
| Provider auth is awkward without key prefixes in the allowlist | `/login` writes `~/.pi/agent/auth.json` once and Docker seeds it; the mode-aware allowlist follow-up removes the friction |
| Cron pi jobs mis-detect readiness | Known degradation, documented in §6; readiness falls through after the poll budget and the prompt still sends |
+235
View File
@@ -0,0 +1,235 @@
# Pi (pi.dev) sessions
Codeman can drive [Pi](https://pi.dev) (`@earendil-works/pi-coding-agent`, MIT) as a
session backend, alongside Claude Code, OpenCode, Codex, Gemini and Antigravity.
`pi` is a sixth **run mode**: its own PTY, its own tmux session, its own tab colour
(rose). It is not a location overlay like Docker or remote-SSH cases, and it is not
a web tab.
Tracking issue: [#206](https://github.com/Ark0N/Codeman/issues/206). The design
rationale behind each decision below lives in `docs/pi-integration-plan.md`.
## Install
```bash
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# or
curl -fsSL https://pi.dev/install.sh | sh
```
Both installers end up going through global npm, so either one uninstalls with
`npm uninstall -g @earendil-works/pi-coding-agent`.
Codeman finds the binary via `which pi` and then the usual global-bin locations
(`~/.local/bin`, `/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`).
**`pi` is a short, generic name**, so unlike the other CLI resolvers Codeman does
not trust a `which` hit on its own: it runs `pi --version` once and requires
semver-shaped output. Anything else is rejected as "not installed" and the
rejected path is logged. Check what it resolved:
```bash
curl -s localhost:3000/api/pi/status | jq
# { "available": true, "path": "/home/you/.local/bin", "version": "0.84.1" }
```
That endpoint carries `version` on top of the shape the sibling `/api/*/status`
endpoints return, precisely so a misresolution is visible rather than presenting
as "the mode just doesn't work".
## Authenticate
Pi supports 15+ providers. Two ways in:
- **OAuth subscription login** — run `/login` inside a pi session. Six providers
support it: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter
and Radius. Credentials land in `~/.pi/agent/auth.json` and pi refreshes them
itself. OpenRouter's flow accepts a pasted redirect URL, which is what makes it
workable over remote SSH.
- **API keys** — exported in the environment of the **Codeman server process**.
⚠️ **Provider API keys cannot be sent as per-session `envOverrides`.** Pi reads
about 34 provider variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
`DEEPSEEK_API_KEY`, `HF_TOKEN`, `BASETEN_API_KEY`, …) that share no common prefix.
Codeman's env allowlist is a single global list applied to every mode at once, so
admitting bare provider keys for pi would widen the allowlist for Claude, Codex,
Gemini and everything else too. Only the **`PI_*`** prefix was added, which covers
every documented pi input: `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`.
`pi auth check` verifies credentials before you start a long run.
Note if you authenticate with a Claude Pro/Max subscription: third-party harness
usage bills as Anthropic "extra usage" per token rather than against plan limits.
## What Codeman wires up
`PiConfig` (per session, persisted in `state.json`, round-trips through respawn):
| Field | Flag | Notes |
| --------------------- | -------------------------------------- | ---------------------------------------------------------------- |
| `model` | `--model <v>` | Accepts `provider/id` and a `:<thinking>` suffix (`sonnet:high`) |
| `provider` | `--provider <v>` | `anthropic`, `openai`, `google`, … |
| `thinking` | `--thinking <v>` | `off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max` |
| `continueSession` | `-c` | Skipped when `resumeSessionId` is set (the two conflict) |
| `resumeSessionId` | `--session <v>` | Ids only, never paths |
| `approveProjectTrust` | `--approve` / `--no-approve` / nothing | Tri-state, see below |
Every value is regex-validated and **dropped** (not escaped) if it fails, because
the result is interpolated into the pane's `bash -c "…"` command.
The Run button sends **no `PiConfig` at all**: pi has no permission prompts to
bypass, and project trust is a decision the person at the terminal makes.
## What Codeman deliberately does NOT wire up
- **`--api-key`.** Never. It would put a provider secret on the spawn command
line, visible in `ps`, tmux server state and logs. `PI_*` overrides go through
socket-scoped `tmux setenv` for exactly this reason.
- **`--tui-mode`.** Pi's default main-screen TUI is the friendly case for a
browser terminal. The fullscreen mode (0.84.0) stays your own runtime choice via
`/settings`.
- **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`,
`--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`,
`--system-prompt`.** Tracked as follow-ups in the plan doc.
## Permission and trust model — read this
**Pi has no permission prompts and no sandbox.** There is no
`--dangerously-skip-permissions` analog and none is needed: tools run with the
user's own permissions, always. A pi session can read, write and execute anything
the Codeman user can. If you need isolation, use a **Docker case** — that is the
isolation story, here as everywhere else in Codeman.
Pi's "project trust" prompt is **not** a safety boundary (upstream says so too).
It gates *loading* repo-local `.pi/` config, extensions and skills, and
*installing* missing project packages. It only appears 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 trigger it.
`approveProjectTrust: true` answers it with `--approve`, which means pi **loads
and executes repository-supplied TypeScript** and runs an npm install for missing
project packages. Treat it exactly as seriously as that sounds.
**Multi-user mode:** for an owner without the privileged-command grant, Codeman
materializes `approveProjectTrust: false` so the pane launches with
`--no-approve` and the prompt never appears. Merely *omitting* `--approve` would
not be a clamp, since pi's own default is to ask and the session user could just
answer yes.
Also worth knowing: `pi auth print-api-key` / `print-bearer-token` and
`pi auth check` mean a pi session can print its own provider credentials by
design. Isolation is Docker.
## tmux extended keys (Shift+Enter)
Pi's editor uses `Shift+Enter` / `Ctrl+Enter` for newline-vs-submit. Without
extended keys, tmux collapses both into a plain `\r`. Upstream recommends:
```tmux
set -g extended-keys on
set -g extended-keys-format csi-u
```
`extended-keys-format` needs tmux 3.5+; on 3.2–3.4 `extended-keys on` alone works
(pi falls back to xterm `modifyOtherKeys`).
Codeman's browser input path sends `\r` for submit, so basic use works
unconfigured — what degrades is newline-in-editor, mostly when you attach to the
pane directly (`sc`).
⚠️ Upstream notes the setting may need a full `tmux kill-server` to take effect.
**Never run `tmux kill-server` on Codeman's socket** — it would kill every live
session, `w1`/`w2`/`w3` included.
**Measured (tmux 3.4, pi 0.84.1): no `kill-server` is needed.** Setting the option
server-scoped on Codeman's own socket takes effect on the ALREADY-RUNNING server;
the next pi session starts without the warning. Existing sessions keep the old
setting until they respawn.
```bash
tmux -L codeman set -s extended-keys on
tmux -L codeman set -s extended-keys-format csi-u # tmux 3.5+ only, see below
tmux -L codeman show-options -s | grep extended # verify
```
On **tmux 3.4 and older, `extended-keys-format` does not exist** and the second
line fails with `invalid option: extended-keys-format`. That is harmless — pi
falls back to xterm `modifyOtherKeys` and `extended-keys on` alone silences the
warning. Run the two lines independently rather than chained.
Pi tells you which state it is in: an unconfigured session prints
`Warning: tmux extended-keys is off. Modified Enter keys may not work.` in its
startup banner, so you can verify the change by starting a new pi session.
⚠️ Use `-L <socket>` and `-s`, never `-g` on your default socket, and never
`kill-server`. Codeman does not set this for you: it is a server-wide tmux option
and silently changing key encoding for every session of every backend is not
Codeman's call to make.
## Typing from the browser (local echo)
On touch devices Codeman buffers typed characters in the `LocalEchoOverlay` and
flushes them to the PTY on Enter. Pi gets that `'buffer'` policy, the same as
Claude, Gemini and OpenCode.
This was an explicit open question, because that policy is exactly what broke
Codex (issues #218/#219/#220/#222): Codex's composer reacts per keystroke, so
buffer-until-Enter starved it. **Measured against pi 0.84.1: it does not
reproduce.** Pi's slash-command picker re-filters on the whole composer content
rather than on per-keystroke deltas, so a one-shot flush of `/set` filters the
picker down to `settings` identically to typing it character by character, and
the delayed `\r` then selects it. Prose prompts flush and submit correctly too.
If a future pi release changes that, the cheap fallback is one `'off'` branch in
`_updateLocalEchoState` (terminal-ui.js); teaching `PredictiveEchoAddon` pi's
composer row is the larger follow-up.
## Docker cases
The agent image (`docker/agent.Dockerfile`) installs pi in its own `RUN` step with
`--ignore-scripts`, kept out of the shared npm block so the flag cannot change how
the other four CLIs install. Rebuild with:
```bash
node scripts/build-agent-image.mjs --no-cache # --no-cache is mandatory
```
Credentials are **seeded**, not shared: `~/.pi/agent/auth.json`, `settings.json`,
`trust.json`, `models.json` and `models-store.json` are mounted read-only and
copied into the container's own `~/.pi/agent`. So an in-container pi never writes
refreshed OAuth tokens back to the host, and `docker commit` exports stay
secret-free. `models.json` is in the list because it holds user-defined custom
providers, which would otherwise silently vanish inside containers.
Only those five files are seeded because `~/.pi/agent` also holds `sessions/`,
`extensions/`, `skills/` and the installed package trees (`npm/`, `git/`), which
on an active host is easily gigabytes.
**Trade-off:** in-container pi sessions are invisible host-side, so `pi -c` inside
a Docker case only sees that container's own history.
## Remote SSH cases
`pi` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'pi'`), because sshd's remote-command PATH does not
include npm's global bin on most hosts. Per-session config and `envOverrides` do
not cross ssh and are rejected rather than silently ignored; use the per-host
command override instead.
## Known gaps
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
idle detection falls back to output-stabilization like the other external CLIs.
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
signal; a Codeman pi extension using it is the highest-value follow-up.
- **No response viewer.** Pi writes JSONL v3 session files under
`~/.pi/agent/sessions/`; nothing reads them yet.
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
token count, neither of which pi prints, so a pi cron job burns its poll budget
and then sends the prompt anyway. It works; it is just slower to start.
- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe
are off** for pi, as for every external CLI.
+2 -2
View File
@@ -1,7 +1,7 @@
# Remote Sessions (SSH)
Codeman can run a session's agent on a **remote host over SSH** instead of the
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, or a plain shell)
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, Pi, or a plain shell)
runs inside a `tmux` server **on the remote host**, so it survives the SSH
connection dropping; Codeman attaches to it the same way it attaches to a local
managed session.
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity'>` — the modes that can run remotely. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir:
+1 -1
View File
@@ -489,7 +489,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — and `~/.config/{gcloud,opencode}`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, and five seeded files from `~/.pi/agent`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.