@pipod/cli 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # Integration-only CI
2
+
3
+ CI again runs the existing `test:integration` entrypoint, now selecting eleven
4
+ explicit reviewed files rather than every file under `test/integration`.
5
+ They exercise actual CLI dispatch, account/project files, HTTP/WebSockets,
6
+ Git/tar subprocesses, OIDC signatures, or extension loading. Some collaborators
7
+ remain fixtures; passing does not prove production remote connectivity.
8
+
9
+ `account-billing.test.ts` contains isolated formatting cases and
10
+ `plan-change-cli.test.ts` contains a fully mocked timeout case. Both files remain
11
+ unchanged and are excluded as a whole. This also omits their genuine HTTP billing
12
+ coverage: the selection is not the full prior integration suite. Extracting those
13
+ existing cases requires a separate reviewed change. Do not reintroduce a directory
14
+ glob, broad name filters, or unit execution to make CI appear complete.
15
+
16
+ Typecheck, lint and build remain required. Restoring CI does not publish a package
17
+ or deploy a server. No production credentials are needed by these fixtures.
@@ -0,0 +1,106 @@
1
+ # Classic runtime coupling
2
+
3
+ The classic runtime is frozen at its current RPC passthrough surface. New
4
+ user-facing features ride the semantic gateway protocol shared with the app —
5
+ never new RPC passthrough methods on the classic runtime. The inventory below
6
+ is an upper bound: removals are allowed; additions and unexpected duplicates
7
+ are not. `@earendil-works/pi-coding-agent` imports stay inside
8
+ `src/client/runtime/` plus the import allowlist below, and only the two named
9
+ modules may patch a live `InteractiveMode` instance.
10
+
11
+ These lists were enforced by the removed `test/unit/classic-architecture.test.ts`
12
+ under the repository's no-unit-tests policy. They are now policy that reviewers
13
+ check by hand; do not weaken them silently.
14
+
15
+ ## Passthrough files
16
+
17
+ - `client/runtime/remote-runtime.ts`
18
+ - `client/runtime/remote-ui.ts`
19
+ - `client/runtime/auth-bridge.ts`
20
+ - `client/runtime/local-extensions.ts`
21
+ - `client/runtime/pod-extension-bridge.ts`
22
+
23
+ ## Frozen RPC passthrough inventory
24
+
25
+ Multiset of `file:enclosingPath:requestType` RPC call sites allowed across the
26
+ passthrough files:
27
+
28
+ ```text
29
+ client/runtime/remote-runtime.ts:createRemoteRuntime:onEvent
30
+ client/runtime/remote-runtime.ts:prompt:prompt
31
+ client/runtime/remote-runtime.ts:refreshRemoteModels:getAvailableModels
32
+ client/runtime/remote-runtime.ts:cycleModel:setModel
33
+ client/runtime/remote-runtime.ts:cycleModel:setThinkingLevel
34
+ client/runtime/remote-runtime.ts:agent.abort:abort
35
+ client/runtime/remote-runtime.ts:session.steer:steer
36
+ client/runtime/remote-runtime.ts:session.followUp:followUp
37
+ client/runtime/remote-runtime.ts:session.abort:abort
38
+ client/runtime/remote-runtime.ts:session.waitForIdle:waitForIdle
39
+ client/runtime/remote-runtime.ts:session.setModel:setModel
40
+ client/runtime/remote-runtime.ts:session.setThinkingLevel:setThinkingLevel
41
+ client/runtime/remote-runtime.ts:session.cycleThinkingLevel:cycleThinkingLevel
42
+ client/runtime/remote-runtime.ts:session.setSteeringMode:setSteeringMode
43
+ client/runtime/remote-runtime.ts:session.setFollowUpMode:setFollowUpMode
44
+ client/runtime/remote-runtime.ts:session.compact:compact
45
+ client/runtime/remote-runtime.ts:session.setAutoCompactionEnabled:setAutoCompaction
46
+ client/runtime/remote-runtime.ts:session.setAutoRetryEnabled:setAutoRetry
47
+ client/runtime/remote-runtime.ts:session.abortRetry:abortRetry
48
+ client/runtime/remote-runtime.ts:session.executeBash:newRequestId
49
+ client/runtime/remote-runtime.ts:session.executeBash:onEvent
50
+ client/runtime/remote-runtime.ts:session.executeBash:bash
51
+ client/runtime/remote-runtime.ts:session.abortBash:abortBash
52
+ client/runtime/remote-runtime.ts:session.setSessionName:setSessionName
53
+ client/runtime/remote-runtime.ts:session.getSessionStats:getSessionStats
54
+ client/runtime/remote-runtime.ts:session.getLastAssistantText:getLastAssistantText
55
+ client/runtime/remote-runtime.ts:session.exportToHtml:exportHtml
56
+ client/runtime/remote-runtime.ts:runtimeHost.newSession:newSession
57
+ client/runtime/remote-runtime.ts:runtimeHost.switchSession:switchSession
58
+ client/runtime/remote-runtime.ts:runtimeHost.fork:fork
59
+ client/runtime/remote-runtime.ts:runtimeHost.dispose.stderrTail:getStderr
60
+ client/runtime/remote-runtime.ts:healStreamingRenderer:refreshStreamingSnapshot
61
+ client/runtime/remote-runtime.ts:createRemoteRuntime.init.setModel:setModel
62
+ client/runtime/remote-runtime.ts:createRemoteRuntime.init.setThinkingLevel:setThinkingLevel
63
+ client/runtime/remote-runtime.ts:createRemoteRuntime.init.abort:abort
64
+ client/runtime/remote-runtime.ts:createRemoteRuntime.init.compact:compact
65
+ client/runtime/remote-runtime.ts:createRemoteRuntime.init.waitForIdle:waitForIdle
66
+ client/runtime/remote-runtime.ts:quitPi:onControl
67
+ client/runtime/remote-runtime.ts:quitPi:shutdown
68
+ client/runtime/remote-ui.ts:RemoteUiBridge.constructor:onLifecycleInvalidated
69
+ client/runtime/remote-ui.ts:RemoteUiBridge.consumeExtensionRequest:respondExtensionUi
70
+ client/runtime/remote-ui.ts:RemoteUiBridge.respond:respondExtensionUi
71
+ client/runtime/auth-bridge.ts:AuthBridge.constructor:onLifecycleInvalidated
72
+ client/runtime/auth-bridge.ts:AuthBridge.consumeExtensionRequest:respondExtensionUi
73
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
74
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
75
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
76
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
77
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
78
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
79
+ client/runtime/auth-bridge.ts:AuthBridge.consumeDialog:respondExtensionUi
80
+ client/runtime/auth-bridge.ts:onAbort:respondExtensionUi
81
+ client/runtime/auth-bridge.ts:AuthBridge.invoke:prompt
82
+ client/runtime/local-extensions.ts:sendAppendEntry:prompt
83
+ client/runtime/pod-extension-bridge.ts:PodExtensionBridge.constructor:onLifecycleInvalidated
84
+ client/runtime/pod-extension-bridge.ts:PodExtensionBridge.invokeSeed:prompt
85
+ client/runtime/pod-extension-bridge.ts:PodExtensionBridge.invoke:prompt
86
+ ```
87
+
88
+ ## Pi import allowlist
89
+
90
+ Outside `src/client/runtime/`, only these modules may import
91
+ `@earendil-works/pi-coding-agent`:
92
+
93
+ - `account/gateway-rpc-codec.ts`
94
+ - `account/gateway-rpc.ts`
95
+ - `account/session.ts`
96
+ - `client/piversion.ts`
97
+ - `client/rpc.ts`
98
+ - `client/stream-events.ts`
99
+ - `oauth.ts`
100
+
101
+ ## InteractiveMode patch sites
102
+
103
+ Only these modules may monkey-patch a live `InteractiveMode` instance:
104
+
105
+ - `client/pod-title.ts`
106
+ - `client/runtime/pod-command-overrides.ts`
@@ -0,0 +1,162 @@
1
+ # Config simplification
2
+
3
+ ## Removed (local schema, hard migration errors)
4
+
5
+ | Key | Why | Migration |
6
+ |---|---|---|
7
+ | `envFile` | Client filesystem layout leaking into the server bundle. The bundle already carries secret *values/names*; the local path was only ever read by `compileBundleSource` and `projectEnvPath`, and the org layer already used a fixed path. | The project env file is always `.pi-pod/env`. Move the file, delete the key. |
8
+ | `initScript` | Same leak. Script *contents* travel in the bundle body (`initScript` string); the config value was only the local read/write path. | The project init script is always `.pi-pod/init.sh`. |
9
+ | `bakeScript` | Same leak, same shape as `initScript`. Bake itself is fully retained — only the path pointer is fixed. | The project bake script is always `.pi-pod/bake.sh`. |
10
+ | `reuse` | Stored reuse is retired server-side: the bundle-write contract rejects it, reads strip it, and resolve omits it. Launches are flag-driven. | Pass `--reuse` to reuse a stopped project pod (fresh pods are the default). Delete the key. |
11
+ | `pi.hostConfig.skills` | True no-op both sides: zero server reads, zero local consumers — the whole local agents directory always syncs. | Delete the key. |
12
+ | `pi.hostConfig.extensions` | Same, both sides. | Delete the key. |
13
+ | `--no-reuse` (CLI) | Redundant once stored `reuse` is gone: fresh pods are the default, so declining reuse is a no-op. `--reuse` alone covers the choice. | Retired as a plain unknown option. Drop the flag. |
14
+ | `init --print-shell-snippet` (CLI) | Shell integration, not configuration. The snippet only tested `cwd` for `.pi-pod/config.json` while the launcher walks to `$HOME`, so it mis-dispatched in subdirectories. Removing the generator breaks nothing installed. | Retired as a plain unknown option. |
15
+ | `--provider` (CLI) | Single-provider build: sandbox is the only backend, so there is nothing to select. | Retired as a plain unknown option. Drop the flag. |
16
+ | `provider`, `deniedProviders` (config) | Single-provider build: sandbox is the only backend, so selection/denial lists are meaningless. Unlike the rows above, these warn-and-ignore (old files keep running) instead of failing closed. | Delete the keys. |
17
+
18
+ Retired config keys fail closed in `validateConfig` (and in `planAccountLaunch`
19
+ before resolve — covering nested `pi.hostConfig` lists and the machine layer
20
+ `~/.pi-pod/config.json`, whose only ever-read keys are the template pin,
21
+ `secretResolver`, and `pi.chords`/`pi.sessionNaming`) because silently ignoring a
22
+ custom script path would read the wrong file. Server-resident bundles carrying
23
+ retired keys (old templates) are stripped with a warning on pull and
24
+ launch-preview — `push` clears them; `diff` strips silently by design (it is
25
+ report-only). These warnings fire against old servers or un-migrated bundles:
26
+ a new server already strips retired keys and flattens legacy Pi settings on
27
+ read. Reads stay lenient, writes fail closed, and the server's own 400
28
+ remains as backstop.
29
+
30
+ ## Stripped from outgoing bundles (never sent)
31
+
32
+ One deep boundary, `stripNonBundleKeys` (`toOutgoingBundleConfig` for raw files),
33
+ drops everything a bundle payload must not contain: the rejected keys
34
+ (`template` — the local pin, a template id travels instead; `reuse`;
35
+ `envFile`/`initScript`/`bakeScript`; `pi.hostConfig.skills`/`extensions`) plus
36
+ client-only metadata (`$schema`, `secretResolver`, `pi.chords`/`pi.sessionNaming`).
37
+ Covered paths: project/user/org-dir compilation, the launch bootstrap preview,
38
+ `templates create/edit --config`, and `settings user|org edit|set` — the settings
39
+ verbs validate the edited config locally first (types and retired keys fail
40
+ before any write request) and throw on non-bundle keys with the fix, so an
41
+ explicit `set reuse true` fails visibly rather than vanishing.
42
+
43
+ ## Retained: `config.json`
44
+
45
+ - `image` — custom images; unset tracks the launcher (`defaultImage`).
46
+ - `resources.{cpu,memoryGB,diskGB}` — sizing feeds the derived image ref and the server request.
47
+ - `egress.{mode,builtins,allow}` — network policy. `builtins` covers only what a committed file cannot state (git remote, env-file base URLs); `allow` admits the rest under `allowlist`.
48
+ - `name` — project identity for pod naming and list scoping; null derives from the directory.
49
+ - `template` — client-only pin selecting the template layer; never travels.
50
+ - `workdir` — not fixed. The server echoes it (`ResolveReport.workdir`, `resolvedConfig.workdir`), `receive` gates paths against it, and custom images may need a non-`/workspace` root.
51
+ - `initTimeoutSeconds` — server-side init budget (request semantics, not layout).
52
+ - `initOnFailure` — server-side init failure policy (`abort`/`prompt`/`continue`).
53
+ - `labels` — server-side pod labels (provisioning).
54
+ - `idleTimeoutMinutes` / `archiveAfterMinutes` — server idle/archive policy, surfaced in status/retention.
55
+ - `pi.model` / `pi.thinking` — defaults; per-launch `--model`/`--thinking`/`--extension`/`--skill`/`--prompt-template` travel as `piOverrides` (pod-local absolute paths, appended to the configured `pi.args`).
56
+ - `pi.command` / `pi.args` — how the pod starts pi (`buildPiArgv`); niche wrappers are real uses.
57
+ - `pi.hostConfig.{settings,packages}` — live gates. `settings` is enforced
58
+ server-side; `packages` is enforced on both sides (local sanitize/filter plus
59
+ the server install gate).
60
+ - `pi.chords` / `pi.sessionNaming` — client-only UI preferences, stripped before anything travels.
61
+ - `secretResolver` / `$schema` — client-only op:// resolution config / schema hint; never travel.
62
+
63
+ ## Retained: CLI surface (complete inventory)
64
+
65
+ Global: `-v/--verbose` (redacted debug logging), `--version`, `-h/--help`, `-y/--yes`
66
+ (answers exactly the prompts each command documents), `--` (everything after
67
+ belongs to pi verbatim). Launch-only: `--template` (apply a template),
68
+ `--on <pod|self>` (co-locate on a machine;
69
+ refuses `--dry-run` and forks with a reason), `--reuse` (warm-disk
70
+ reuse of this repo+branch's newest stopped pod), `--no-seed` (empty workspace),
71
+ `--dry-run` (resolve + print, create nothing). Out-of-scope flags warn and are
72
+ ignored, except `--dry-run`/`--on`-with-fork which are usage errors.
73
+
74
+ - Launch (bare `pipod`, the default): prompt words and `--model`/`--thinking`
75
+ (server `piOverrides`) plus `--tui-mode` (local layout only) after `--`;
76
+ anything else after `--` is rejected with a pointer to `pi.args`.
77
+ - `list [pod]` (`ls`): stable short refs; project/template scoping by default.
78
+ `-a/--all` (whole account), `--archived` (state filter, orthogonal to scope),
79
+ `--template` (filter), `-q/--quiet` (ids only, for piping). With a pod arg,
80
+ shows that machine's co-located group.
81
+ - `attach [pod]` (`a`): rejoin a session; archived pods offer restore (`-y`
82
+ accepts). After `--`: startup prompt words and `--tui-mode`.
83
+ - `stop [pod…]`: immediate stop, disk kept; co-located pods stop with the host.
84
+ It never confirms, so `--yes` warns instead of pretending otherwise.
85
+ - `fork <pod>`: new pod from a session fork (`--session` picks the file).
86
+ Accepts the launch template/dry-run flags; `--on` refused, reuse
87
+ never applies (fresh sandbox, workspace not copied — hence the warning).
88
+ - `rename <pod> <name>`, `send [pod] <path>` (host→pod, `-y`), `receive [pod]
89
+ <path>` (pod→cwd, workdir-relative or absolute inside it, `-y`).
90
+ - `archive [pod…]`: logical archival. `-a/--all`, `--template`, `--idle
91
+ <duration>` (4h/2d/30m) select; `--dry-run` previews; `-y` skips the bulk
92
+ confirm. `restore [pod…]` reverses it (`-a/--all`, `-y`; no filters).
93
+ - `gc`: retention report over every pod (always global — it takes no `--all`).
94
+ `--delete` performs the reclaim, `-y` skips its confirm.
95
+ - `jobs [action]`: server-run scheduled launches from local job files.
96
+ `list` (default, `-q`), `show`, `runs`, `activate`, `pause`, `resume`, `rm`,
97
+ `push` (promote files; `--org` is a boolean after the action — a login-style
98
+ `--org <alias>` placed before `jobs` is rejected rather than mis-scoped), `pull` (to
99
+ `~/.pi-pod/jobs/` or `--project`'s `.pi-pod/jobs/`), `diff` (exit 1 on drift).
100
+ `-y` skips confirms / overwrites on pull.
101
+ - `credentials [action]`: account model-provider sign-ins (tokens stay server-side).
102
+ `list` (default), `connect [<provider>]` (bare = every connectable provider;
103
+ `--api-key` for key entry, `--from-pi` to import the local pi key),
104
+ `reconnect`, `test`, `remove|rm [--yes]`.
105
+ - `secrets [action]`: four layers (org, user, template, project-file wins).
106
+ `list` (default: winner + shadows per name), `set <scope> <NAME>[=value]`
107
+ (hidden prompt/stdin without `=`), `rm|remove|unset`, `sync <scope>` (server
108
+ scopes only; `--file`, `--prune`, `--dry-run`). `template/<name>` addressing;
109
+ provider credentials refused in template scope.
110
+ - `templates [action]`: personal by default, `--org`/`share` for org-wide.
111
+ `list`, `show`, `create <name>` (`--config <file>` for an explicit config,
112
+ `--from-here` to compile the project — mutually exclusive; `--init-script` /
113
+ `--bake-script` to layer contents; `--description`; `--with-secrets` requires
114
+ `--from-here`), `edit` (`--name/--description/--config/--init-script/--bake-script`;
115
+ `--org` rejected — org-wide goes through `share`; whole-snapshot replace goes through `push`), `share` (one-way),
116
+ `rm|delete [-y]`. A global `--yes` is honored (no false scope warning).
117
+ - `push [template [<name>]|user|org]`: whole-bundle replace from the layer's
118
+ local source (project `.pi-pod/`+`.pi/`, user `~/.pi-pod`+`~/.pi/agent`, org
119
+ `~/.pi-pod/org/` or `--dir`), after a diff preview. `--with-secrets` uploads
120
+ env values (else a prompt per bundle); `-y` applies without confirming.
121
+ - `pull [layer]`: reverse of push (same sources; `--dir` for org; `-y`).
122
+ Client-only keys, host-coupled settings, and model credential values survive;
123
+ server scripts with no local file warn instead of writing.
124
+ - `diff [layer]`: same sources, secrets by name only; exit 1 on drift (`--dir`
125
+ for org; no `-y` — it never writes).
126
+ - `settings <user|org> [show|edit|set <key> <json-value>]`: config-only
127
+ compare-and-swap edits (scripts/Pi files untouched — that is `push`).
128
+ `show` prints JSON; `edit` uses `$VISUAL`/`$EDITOR`; `set` takes a dotted key
129
+ plus a JSON value. Templates are not a settings scope (hint points at
130
+ `templates`).
131
+ - `login`: Zitadel PKCE in the browser. `--server`, `--issuer` (self-hosted),
132
+ `--org` (domain pin), `--token` (dev/CI JWT). `logout` revokes and deletes
133
+ auth; `whoami` reports server/user/org/permissions (exit 1 signed out);
134
+ `account` / `org-admin` open the consoles.
135
+ - `doctor`: per-layer diagnosis (auth, server, org, secrets, resolve) plus the
136
+ workspace-seed prediction. `--template` plans the checked launch.
137
+ Works signed out; exit non-zero on failure.
138
+ - `init`: scaffolds `.pi-pod/` (`config.json` with no asserted defaults,
139
+ `env.example`, owner-only `env`, executable `init.sh`; no `bake.sh` until
140
+ needed). `--force` overwrites scaffold files (never `env`). Warns when
141
+ shadowing an enclosing project.
142
+ - `update`: self-upgrade (registry or git checkout + rebuild); `--dry-run`
143
+ reports only.
144
+
145
+ ## Standardized without a flag day
146
+
147
+ Local validation deliberately does not claim to pre-validate everything the
148
+ server enforces: long-retired keys (`repo`, `autoDelete`, `orphanTtlMinutes`,
149
+ `pi.version`, detach sequences, …) warn as ordinary unknown keys and still run
150
+ (pinned by tests) — the server bundle contract is authoritative for anything
151
+ beyond the simplification-retired set above, and its 400 names any key it will
152
+ not store.
153
+
154
+ - Bundle bodies still carry script *contents* and flat Pi files; only config
155
+ path pointers were removed. Conventional paths are fixed:
156
+ `.pi-pod/env`, `.pi-pod/init.sh`, `.pi-pod/bake.sh` (scaffold, secrets,
157
+ compile, pull, and launch-preview all share the `PROJECT_*` constants; the
158
+ org layer already did).
159
+ - `TemplateBody.piSettings` accepts only the flat file map on writes; reads
160
+ still accept legacy `{user,project}` via `flatPiSettings` for old servers and
161
+ un-migrated rows (a new server flattens stored legacy rows on read; they
162
+ normalize on next write).
@@ -0,0 +1,96 @@
1
+ # Extension aux bridge: local rendering + pod-side completions
2
+
3
+ Server-attested pod extensions run twice: in the pod (authoritative) and in the
4
+ launcher (rendering hooks only — entry/message renderers, markdown transformers —
5
+ via the launcher's own `ExtensionRunner`). Two bridges connect the halves.
6
+
7
+ ## 1. Command ownership: the pod owns every slash command by default
8
+
9
+ This is deliberate. An attested extension's `/name` can close over pod-side state
10
+ (`pi-lightweight-llm`'s `/plan`-style flows, `pi.exec` handlers): routing it to the
11
+ launcher unconditionally would hijack that state. So bare `/name` input always
12
+ crosses the wire exactly once for the pod copy to run. The local copy never
13
+ intercepts, never shadows autocomplete, and never double-executes.
14
+
15
+ Run a command locally only when you mean it:
16
+
17
+ - `/pod local <name> [args]` — run the launcher-side copy with the real TUI
18
+ context. Strict: an unregistered name errors honestly and is never forwarded,
19
+ so one input can never execute in both places. `/pod local` alone lists the
20
+ locally rendered commands. Tab-completion covers `/pod local <name>`.
21
+ - `localCommands` (session option, default empty) — names that run locally
22
+ directly, e.g. `["transcript"]`. An allowlisted name with no local
23
+ registration falls through to the pod (single owner, no duplicate run).
24
+
25
+ Same-name registrations from different local extensions collide loudly: `/pod
26
+ local` reports every claimant's `sourceInfo` and runs none, instead of guessing.
27
+
28
+ ### pi-lightweight-llm requirement (unchanged upstream, v0.1.4)
29
+
30
+ Its summary rendering needs the TUI context (`supports: ctx.mode === "tui"`),
31
+ which only the launcher side has. It registers `transcript`, `tool-summaries`,
32
+ and `lightweight-llm`. Out of the box `/transcript` runs in the pod, where the
33
+ RPC-side copy cannot render summaries. To get local summaries, either run
34
+ `/pod local transcript summary…` per invocation, or opt `transcript` into
35
+ `localCommands` for the session. Generic aux model calls (`find` /
36
+ `hasConfiguredAuth` / `complete` below) work regardless — no setting needed.
37
+
38
+ ## 2. Auxiliary completions: `ctx.modelRegistry` backed by the pod
39
+
40
+ Pod credentials never cross to the launcher, so the launcher-side registry is a
41
+ facade:
42
+
43
+ - Metadata (`find`, `getAll`, `getAvailable`, `hasConfiguredAuth`,
44
+ `getProviderDisplayName`, `isUsingOAuth`, …) reads the client-side model
45
+ catalog + auth snapshot: point-in-time, pod-authoritative, sync just like
46
+ native. Secret-bearing (`getApiKeyAndHeaders`, `getProviderAuth`,
47
+ `getApiKeyForProvider`) and provider-mutating (`registerProvider`, …) methods
48
+ throw explained errors.
49
+ - `complete(model, context, options)` runs pod-side through the semantic
50
+ `aux_complete` gateway message, executed by the pod's real `ModelRegistry`
51
+ as a turn-external call: no transcript entries, no main-model change,
52
+ `cacheRetention: "none"` (accepted because unchanged callers always pass it;
53
+ any other value is rejected). Old pods (extension < 11) fail with an actionable
54
+ "restart Pi in the pod" error, never a hang.
55
+
56
+ ### Text-only contract (explicit, not full native parity)
57
+
58
+ Only `{systemPrompt, messages}` with user/assistant text cross. Everything else
59
+ is **rejected, never silently dropped**: `tools` and sampling options
60
+ (`temperature`, `samplingParams`, …), transports, headers/env, image/audio and
61
+ tool-result content, thinking blocks. The pod runs the call through its real
62
+ `ModelRegistry.complete` with the native `reasoningEffort` option passed through
63
+ verbatim (including `"off"`) — thinking semantics stay exactly the pod's native
64
+ per-API behavior, provider-specific with no bridge-level translation. Empty-text non-terminal outcomes (toolUse /
65
+ deferred / pending) fail rather than fabricate `ok:true` with empty text.
66
+ Usage (including cost) passes through untouched.
67
+
68
+ ### Bounds (each side enforces its own; the lower wins)
69
+
70
+ | Bound | Value |
71
+ |---|---|
72
+ | Concurrency, per launcher session / per pod session | 4 (`aux_busy`) |
73
+ | Timeout default / cap | 60s / 5min client; gateway answers by min(timeout, 120s); pod ceiling ~130s |
74
+ | System prompt | 16KB chars |
75
+ | Messages | 16 entries × 64KB chars |
76
+ | maxTokens default / cap | 2048 / 4096 |
77
+ | Wire request/response | 256KB base64url each |
78
+
79
+ ### Cancellation and lifecycle
80
+
81
+ Every call has its own id, deadline, and `AbortSignal`: aborting sends
82
+ `aux_cancel` and fails only that call — never the main turn. An already-aborted
83
+ signal fails before any transport use; a reply that arrives after abort,
84
+ timeout, or session invalidation is discarded, never returned as success.
85
+ Disconnect and session replacement (`/new`, `/fork`, `/resume`, `/pod switch`,
86
+ reconnect recovery) discard pending waits before rebind; in-flight pod
87
+ executions are the gateway's to cancel, and their late replies correlate to
88
+ nothing. Shutdown emits `session_shutdown(quit)` to local handlers while the UI
89
+ and caches are still live, then invalidates the host.
90
+
91
+ `session_tree` is runner-local in pi (never on the wire): the runtime
92
+ synthesizes it after local tree navigation so TUI-mode capabilities rescan,
93
+ with the native `{newLeafId, oldLeafId, fromExtension}` payload.
94
+ Session start/shutdown use pi's own reasons (`startup/new/resume/fork`,
95
+ `quit/new/resume/fork`) with session-file handoffs, so mode-gated capabilities
96
+ reset exactly like in-process pi.
@@ -0,0 +1,58 @@
1
+ # Local image attachments
2
+
3
+ The TUI runs on your machine; the agent runs in the pod. When your prompt names an
4
+ image file that exists **on your machine**, pi-pod reads it locally, processes it with
5
+ pi's own image pipeline, and sends it inline with the prompt — the pod never needs the
6
+ file. Anything that does not resolve locally is left byte-identical, so pod-side `@`
7
+ references and pod paths keep working exactly as before.
8
+
9
+ ## What gets attached
10
+
11
+ - Clipboard pastes (pi saves the clipboard image under your `/tmp` and inserts the
12
+ bare path), drag-dropped files, `@screenshot.png` mentions, and typed paths —
13
+ absolute, `~/…`, `./…`, `../…`, quoted, or `file://…`. Bare absolute paths stay
14
+ eligible on purpose: that is exactly what pastes and drag-drops insert.
15
+ - Detection is by content sniff, not suffix: PNG, JPEG, GIF, and WebP go through pi's
16
+ resize to inline limits (2000px, ~4.5MB); BMP, animated PNG, and anything else gets
17
+ one PNG conversion attempt first.
18
+ - The sent text keeps your words and gains one marker line per image:
19
+ `<file name="shot.png"></file>` (or an `[Image omitted: …]` note when a real local
20
+ file could not be attached). The marker carries only the **basename**, XML-escaped —
21
+ the pod can never open the host path, so the full path would only leak your
22
+ directory layout into the persisted transcript. The marker is what you see echoed
23
+ in your own message.
24
+
25
+ ## Bounds
26
+
27
+ These mirror what the gateway enforces on `prompt` / `steer` / `follow_up`, so a
28
+ prompt built here is never rejected server-side (a rejected prompt sends nothing at
29
+ all — degrading an over-budget image to a text reference is strictly more useful):
30
+
31
+ - At most **8 images per prompt, including images already attached** (e.g. by an
32
+ extension). Extras stay plain text and you get a warning.
33
+ - At most **8 MiB decoded per image** and **32 MiB of base64 total** across the turn
34
+ (~24 MiB decoded). Over-budget images are omitted with a note, never sent.
35
+ - Reads are bounded (one open, one stat, one capped 24 MB read), regular files only —
36
+ never directories, devices, or sockets. A file that grows mid-read is detected, not
37
+ loaded. Only paths your prompt text actually names are read; nothing is scanned
38
+ speculatively.
39
+ - Extension-supplied message parts are forwarded verbatim and never scanned: only text
40
+ you typed (or pasted) names files on purpose. A bare word like `a.png` in prose
41
+ does **not** attach (only `@a.png` does), and URLs and `data:` URIs never attach.
42
+
43
+ ## What the pod sees
44
+
45
+ Images ride the existing `prompt` / `steer` / `follow_up` RPC `images` field — the
46
+ same shape pi's own `@file` CLI attachments produce. No new protocol, no server
47
+ change. One-shot prompts (`pipod attach <pod> -- "look at ./shot.png"`) attach the
48
+ same way; attach failures are reported on stderr there, as a TUI notice here.
49
+
50
+ ## Limits
51
+
52
+ - The image bytes travel **inline** with the turn; the file itself is not copied into
53
+ the pod. If the agent later tries to `read` the host path inside the pod, that read
54
+ fails — but the model already has the image, so it does not need to. (Copying the
55
+ file into the pod workspace would need a server-side upload channel; that is
56
+ intentionally out of scope.)
57
+ - Pod-side images need no help: `@` completion already resolves against the pod
58
+ workspace, and the pod-side `read` tool returns those images itself.
@@ -0,0 +1,132 @@
1
+ # Personal workstations, and the SaaS fields the client hides
2
+
3
+ pi pod ships as one client for two products.
4
+
5
+ - **Self-hosted (open source).** The sandbox runtime runs beside the server on hardware the
6
+ operator already pays for. There is a shared fleet, and there is no plan, no metered hour and
7
+ no spend cap anywhere in the system.
8
+ - **SaaS.** Every user gets a whole machine of their own — a **workstation** — which sleeps when
9
+ idle and is started again on demand. Hours are metered and capped.
10
+
11
+ The client is written so that the second product's surfaces simply do not exist under the first.
12
+ This file is the contract it implements, and it is also what a server change has to match.
13
+
14
+ ## 1. Waking a workstation
15
+
16
+ A workstation that is asleep or coming up refuses any call that needs it. That refusal is
17
+ **HTTP 503** with a typed detail:
18
+
19
+ ```json
20
+ {
21
+ "error": "Your workstation is starting. This may take several minutes",
22
+ "detail": {
23
+ "kind": "admission",
24
+ "reason": "host_starting",
25
+ "resource": "transitions",
26
+ "unit": "count",
27
+ "retryable": true,
28
+ "hostId": "boat-…",
29
+ "statusHref": "/v1/workstations/boat-…",
30
+ "state": "starting",
31
+ "retryAfterMs": 10000,
32
+ "operation": {
33
+ "id": "…", "kind": "resume", "state": "running", "phase": "command:activate",
34
+ "deadlineAt": "…", "retryAt": null, "errorCode": null
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ `kind`/`resource`/`unit` are constants and are what identify the shape. `reason` is one of
41
+ `host_starting`, `host_stopped`, `host_deleted`, `host_retired`,
42
+ `host_requires_reconciliation`, `boat_starts_disabled`; `retryable` on the wire is
43
+ authoritative. The WebSocket equivalent is close code **4420** with reason `host_starting`,
44
+ `host_stopped` or `host_archived` — the same code an idle pod uses, so the reason is what tells
45
+ them apart.
46
+
47
+ `src/account/workstation.ts` validates all of it as strictly as the server does on the way out:
48
+ the host id against the workstation route's own rule, `statusHref` **recomputed from the id and
49
+ compared** rather than trusted, `retryAfterMs` bounded to 0–300 s, the operation reduced to the
50
+ seven fields `GET /v1/workstations/:hostId` exposes. A malformed field means "no detail", never
51
+ a forged one.
52
+
53
+ Then a real launch waits: poll the durable status, re-issue the same request each cycle, and
54
+ render elapsed time plus the server's own phase. The operation deadline is not a client exit.
55
+ The wait ends when the request succeeds, the user cancels, the refusal is terminal, or two
56
+ observed start cycles end stopped without the request succeeding. It does not ask the user to
57
+ come back and run the command again. A dry run reports the demand and does not wait.
58
+ Cancelling ends the client's wait, not the machine's start.
59
+
60
+ Install a waiting client from the merged source commit (`npm ci && npm run build` in `pi-pod`,
61
+ then point the launcher at that `dist/cli.js`). `pipod update` only upgrades a git checkout or
62
+ an npm install; a release directory under `~/.local/share/pipod/releases` is neither.
63
+
64
+ ### The numbers, and why the copy says "several minutes"
65
+
66
+ Measured in production on 2026-09-10 — ten operations across two hosts in one evening. These are
67
+ measurements, not an SLA:
68
+
69
+ | transition | measured |
70
+ |---|---|
71
+ | cold create → ready (host with eight pods) | 407 s |
72
+ | cold create → ready (empty host) | 708 s |
73
+ | resume → ready | 243 s, 690 s, 483 s, 312 s, 357 s |
74
+ | pod launch → ready on an already-warm workstation | 46 s |
75
+
76
+ So: no copy anywhere promises seconds, and nothing counts down to a number the server did not
77
+ send. A workstation wait must never render as fleet pressure ("the fleet has no room", "ask an
78
+ owner to register capacity") — nobody registers a workstation — and never as data loss. A
79
+ stopped workstation keeps every workspace on its own disk.
80
+
81
+ ## 2. The optional plan block
82
+
83
+ `GET /v1/me` may carry a `billing` object. Every field is independently optional:
84
+
85
+ ```jsonc
86
+ "billing": {
87
+ "plan": { "id": "standard", "name": "Standard" },
88
+ "period": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z" },
89
+ "activeHours": { "used": 12.4, "included": 200 },
90
+ "spendCap": { "limitUsd": 40, "spentUsd": 3.2, "state": "ok" },
91
+ "trial": { "state": "active", "endsAt": "2026-09-24T00:00:00Z", "hoursRemaining": 8 },
92
+ "state": "ok"
93
+ }
94
+ ```
95
+
96
+ `spendCap.state` ∈ `ok approaching reached`; `trial.state` ∈ `active expired none`; `state` ∈
97
+ `ok cap_reached trial_expired payment_failed grace`. Numbers are finite and non-negative;
98
+ `used`/`spentUsd` may exceed the allowance, because overage is a real thing that happens.
99
+
100
+ `pipod list` renders one dim footer line from it:
101
+
102
+ ```
103
+ pi pod Standard · 12.4 of 200 active hours · spend cap $40 ($3.20 used) · period ends 2026-10-01
104
+ ```
105
+
106
+ Each segment is dropped independently when its data is missing.
107
+
108
+ **When the object is absent the whole surface is absent** — no zeroes, no "unknown", no empty
109
+ heading, no error. That is the edition boundary: a self-hosted operator must never be shown a
110
+ spend cap they do not have. `--quiet` is unaffected in both editions.
111
+
112
+ The client never computes, projects or prices anything. "12.4 of 200 active hours" is allowed
113
+ because the server sent both numbers; "48 hours left, about $12 more" is not.
114
+
115
+ ## 3. Billing refusals
116
+
117
+ When the account's own billing state blocks a start, the refusal carries
118
+ `detail.reason` ∈ `spend_cap_reached`, `trial_expired`, `payment_required`, `billing_required`,
119
+ `payment_failed`, optionally with `limitUsd`, `spentUsd`, `periodEnd`, `endsAt`,
120
+ `hoursRemaining`, `graceDaysRemaining`.
121
+
122
+ The client says what happened, what it cost and what to do — using only the figures that
123
+ arrived, and naming no price, plan or policy of its own. These are decisions, not waits: they
124
+ never enter a retry loop.
125
+
126
+ ## 4. If you are changing the server
127
+
128
+ Adding these fields is additive and safe: an older client ignores what it does not know, and a
129
+ server that omits them keeps the surface hidden. Keep `statusHref` derivable from `hostId`,
130
+ keep `reason` inside its allowlist, and keep every `billing` field optional — the client's
131
+ tests assert both the present and the absent shape, and the absent shape is the open-source
132
+ product.
@@ -0,0 +1,77 @@
1
+ # Sandbox cost controls — client behavior
2
+
3
+ Product policy (the server enforces; this client only presents it):
4
+
5
+ - **Standard shape:** 2 shared vCPU / 4 GiB memory / 20 GiB writable disk per sandbox.
6
+ Shared image layers are not charged repeatedly; usable space is slightly under quota
7
+ (filesystem metadata).
8
+ The `sandbox` fleet holds a documented full local-disk
9
+ reservation per stopped workspace, so stopped rows render `local disk retained`.
10
+ A cold-storage claim additionally needs
11
+ sandbox-confirmed archive (`sandboxState=archived`); a bare logically-hidden row claims
12
+ nothing about storage.
13
+ - **Concurrency:** up to **20 concurrent sandboxes per platform user**,
14
+ across organizations. Co-located (`--on`) children share the host's machine and hold no
15
+ extra slot. The server's configured value is authoritative — when the client names a cap
16
+ number it always quotes the server's refusal, never a local constant.
17
+ - **Retention:** 15 minutes of genuine inactivity stops a pod (waiting on a model,
18
+ dialog, network, or detached build is not idle); 60 stopped minutes archives it.
19
+ Archive uploads and verifies before local disk is released. (Durations are the
20
+ platform defaults; an organization policy may set its own — clients never hardcode
21
+ them into refusal copy.)
22
+ - **Larger memory:** 8 GiB is capability-gated opt-in. The standard stays 4 GiB. The client
23
+ never clamps an 8-GiB request down itself: it either receives 8 GiB on a qualified host
24
+ or a clear unsupported/capacity refusal, and any server clamp is printed verbatim.
25
+
26
+ ## Statuses the client shows
27
+
28
+ | What you see | Meaning | Cost to wake |
29
+ | --- | --- | --- |
30
+ | `running` | Running sandbox | — |
31
+ | `stopped` | Active row, stopped sandbox; workspace files kept on local disk | `attach` restarts in seconds |
32
+ | `stopped · cold storage` | Archived sandbox under an active row | Restores in seconds-to-minutes depending on workspace size |
33
+ | `archived` / `archived · cold storage` | Archived row (`list --archived` shows it), its sandbox stopped | `restore`, then attach as above |
34
+ | `failed` | A launch or start that ended with a reason | `pipod gc --delete` removes it |
35
+ | `preparing` / `provisioning-sandbox` / `running-init` | Still starting | Wait; do not create a duplicate |
36
+
37
+ Archive is hide, not delete: it stops the pod and hides it. `stop` releases compute now and
38
+ keeps the disk without hiding the pod. Cold storage follows the server's archive policy.
39
+ `restore` returns the row; attach starts the sandbox when needed. There is no universal
40
+ 10-second restore promise: expect seconds for stopped disks and seconds-to-minutes for
41
+ archived workspaces depending on size, longer near quota.
42
+
43
+ ## Capacity errors
44
+
45
+ - **Per-user / org concurrency (409):** stop a running pod (`pipod stop <pod>`) or wait for
46
+ idle sleep, then retry. Do not relaunch in a loop.
47
+ - **Fleet at capacity (503):** retry shortly without creating duplicates; stopped pods keep
48
+ disks and archived pods restore later. Persistent pressure needs registered capacity
49
+ (an owner action), not client retries.
50
+ - **Temporarily unavailable (409):** the pod is mid-transition — retry shortly.
51
+ - **Unsupported shape (400):** the worker shape is not available; retry at standard size.
52
+ - **Restore-first (409):** `pipod restore <pod>`, then retry.
53
+
54
+ The client renders these from validated codes and numbers only. Raw backend text, URLs
55
+ with credentials, and secrets stay redacted. A lost launch reply is resolved by listing
56
+ first (adopt the landed pod) rather than blindly relaunching.
57
+
58
+ ## Bounded capacity wait (capacity contract §1)
59
+
60
+ When the fleet is full, a valid entitled launch/wake queues instead of failing at once
61
+ (rollout-gated server-side; older servers omit the field and keep immediate errors):
62
+
63
+ - `waiting` — the pod holds one concurrency slot but no host reservation. The launch
64
+ narrates `waiting for capacity — …` once per reason with time left on the final
65
+ deadline (default 60s total). Ctrl-C before Pi starts keeps its existing meaning:
66
+ the fresh pod is deleted, which ends the wait. `DELETE /v1/pods/:id/capacity-wait`
67
+ (client `cancelCapacityWait`) ends the wait cooperatively and keeps the pod row.
68
+ - `admitted` — a retry found room; provisioning continues normally.
69
+ - `expired` — FINAL failure for this launch: no room before the deadline. The client
70
+ reports the reason copy and stops; launch again by hand, never auto-duplicate.
71
+ - `cancelled` — the wait was cancelled (or the pod deleted); the row survives deletion
72
+ only if it was not the thing deleted.
73
+
74
+ Wait progress reuses the existing `GET /v1/pods/:id?wait=<ms>` long poll — no new
75
+ client request fields. Typed `detail.reason` values (`memory/cpu/disk/transition/network_capacity`,
76
+ `memory_debt`, `fairness_degraded`, `fleet_capacity`, `unsupported_shape`) map to the
77
+ same actionable copy as the immediate errors.