Scenarios - memo-inbox: mirrored by copying; the live directory was not moved or modified and the service was not restarted. All four tracked files match byte for byte (pi-diff.sh reports SAME). Marked deploy = "mirror" so deploy-scenario.sh refuses --apply: applying a mirror would invert the direction of truth and could change a service in daily use. - curator: target configuration, not yet deployed. .pi/SYSTEM.md replaces pi's coding-assistant prompt; durable role text is in .pi/APPEND_SYSTEM.md; profile.toml is the single source of truth for the launch contract. - pi-grok: registered only. It is genuinely a coding agent, so the isolation baseline does not apply in full. Corrections to the documentation, found by testing rather than by reading - AGENTS.override.md does NOT block parent-directory context files; it only shadows its own directory. Verified: with an override file in the workspace, a marker in /tmp/AGENTS.md still reached the system prompt. The only effective switch is --no-context-files, so durable role text must live in .pi/APPEND_SYSTEM.md, which is a system-prompt file and unaffected by -nc. Verified end state: no coding-assistant framing, no pi-docs block, own identity and role text present, no parent pollution, only own skills/tools. - PI_CODING_AGENT_DIR isolates settings/models/auth/trust/extensions/skills/ prompts/themes under the agent directory -- stronger than the --no-* flags because it also repoints credentials -- but does NOT cover ~/.agents/skills. Measured: find-skills, modsearch and summarize still leak. So it complements --no-skills rather than replacing it. - --append-system-prompt accepts a file path, which pi-grok relies on. - cwd is what anchors .pi discovery: a probe that forgot cwd silently lost .pi/SYSTEM.md and kept the coding-assistant persona. Tooling (all dry-run by default; none of them restarts a service) - pi-diff.sh: compares tracked config against the live install in both directions, with a key-redacted comparison for models.json - deploy-scenario.sh: installs a workspace and renders profile.toml into .pi/launch.json, then checks that every referenced path exists - deploy-runtime.sh: renders models.json from its template, refusing placeholder or missing keys. Verified byte-identical to the live file - pi-backup.sh / pi-restore.sh: archives outside the repo, sha256 manifest verified before any restore, live paths preserved rather than overwritten Fixed while testing: pi-backup.sh compared the destination against the repo root literally, so a relative --dest ./backups wrote credential archives into the work tree. Now canonicalised with realpath; ./backups, an absolute in-repo path and ./docs/../backups are all refused.
440 lines
17 KiB
Markdown
440 lines
17 KiB
Markdown
# Pi Runtime Notes
|
|
|
|
> Verified against **pi 0.84.3** on Debian 13 / node v22.23.2, 2026-08-27.
|
|
> Every claim below is either quoted from `dist/` source or reproduced by the
|
|
> probe harness in [`evidence/probe-harness/`](evidence/probe-harness/).
|
|
> Results: [`evidence/2026-08-27-isolation-probe.md`](evidence/2026-08-27-isolation-probe.md).
|
|
|
|
This file records behaviour that the official docs either do not state or state
|
|
only in passing, but which determines whether a dedicated Pi agent works at all.
|
|
Re-verify after every `pi update`.
|
|
|
|
---
|
|
|
|
## 1. The skills section requires an active `read` tool
|
|
|
|
`dist/core/system-prompt.js`:
|
|
|
|
```javascript
|
|
const hasRead = tools.includes("read");
|
|
// Append skills section (only if read tool is available)
|
|
if (hasRead && skills.length > 0) {
|
|
prompt += formatSkillsForPrompt(skills);
|
|
}
|
|
```
|
|
|
|
`tools` is the **active** tool-name list, passed as `selectedTools` from
|
|
`agent-session.js:_rebuildSystemPrompt(this.getActiveToolNames())`.
|
|
|
|
**Consequence:** `--no-tools` silently removes the entire skills block.
|
|
Any `SKILL.md` is then unreachable, and `--skill <path>` becomes a no-op.
|
|
|
|
Probe evidence (`--no-tools` vs `--no-builtin-tools`):
|
|
|
|
| Flags | `available_skills` in prompt | prompt length |
|
|
|---|---|---|
|
|
| `--no-tools` | **false** | 1859 |
|
|
| `--no-builtin-tools` | true | 3413 |
|
|
|
|
This was a live defect in the Curator scenario: it ran `--no-tools --skill …`
|
|
for its whole lifetime, so its 64-line media policy never reached the model.
|
|
|
|
## 2. Skill injection is progressive, not full text
|
|
|
|
`formatSkillsForPrompt` emits only `<name>`, `<description>` and `<location>`
|
|
per skill, plus the instruction "Use the read tool to load a skill's file when
|
|
the task matches its description."
|
|
|
|
So the body of a `SKILL.md` costs nothing up front but **is only ever read via
|
|
the `read` tool**. A skill body is unreachable without a working `read`.
|
|
|
|
Corollary: if you override `read` with a path-restricted implementation, the
|
|
allowlist must include the skill directory or skill bodies cannot be loaded.
|
|
|
|
## 3. `--skill` does not double-load an auto-discovered skill
|
|
|
|
`dist/core/skills.js:loadSkills()` dedupes twice — by canonical realpath, and
|
|
by skill `name`. Defaults are added first, so on a name collision the
|
|
**auto-discovered** skill wins and a collision diagnostic is emitted.
|
|
Same file via symlink is skipped silently.
|
|
|
|
## 4. Tool gating semantics
|
|
|
|
| Flag | registry filter | initial active set | extension tools |
|
|
|---|---|---|---|
|
|
| *(none)* | none | `settings.defaultTools ?? [read,bash,edit,write]` | all activated |
|
|
| `--tools a,b` | **strict allowlist across every source** | `[a,b]` | must be named to exist |
|
|
| `--no-tools` | empty allowlist | `[]` | **also killed** |
|
|
| `--no-builtin-tools` | none | `[]` | **all activated** |
|
|
| `--exclude-tools x` | removes `x` | filtered | applies to all sources |
|
|
|
|
From `dist/core/agent-session.js`:
|
|
|
|
```javascript
|
|
const isAllowedTool = (name) =>
|
|
(!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);
|
|
```
|
|
|
|
Under `--no-builtin-tools` the built-ins stay **registered but inactive**, so an
|
|
extension can re-enable them with `pi.setActiveTools([...])`. Under `--no-tools`
|
|
they were never registered and cannot be revived.
|
|
|
|
Probed: `--no-builtin-tools` + one extension →
|
|
`ACTIVE_TOOLS=["probe_plain_schema","read"]`, while `bash/edit/write/grep/find/ls`
|
|
remain listed as `[builtin]` and inactive.
|
|
|
|
## 5. `registerTool` accepts a plain JSON Schema object — verified
|
|
|
|
Declared type is TypeBox `TSchema`, but a raw JSON Schema object works:
|
|
|
|
```typescript
|
|
pi.registerTool({
|
|
name: "probe_plain_schema",
|
|
parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } as any,
|
|
...
|
|
});
|
|
```
|
|
|
|
Probe result: the tool appears as `probe_plain_schema[cli]` and is active.
|
|
|
|
**Why this matters:** a backend can serve its tool definitions as JSON Schema
|
|
over HTTP and a generic bridge extension can register them at runtime, so the
|
|
schema has exactly one source of truth. See `shared/extensions/`.
|
|
|
|
Note `--tools` is a *registry-level* allowlist, so dynamically registered tools
|
|
must either be named in `--tools` or you must use `--no-builtin-tools`
|
|
(no allowlist) and let the extension govern the active set.
|
|
|
|
## 6. An extension can override a built-in tool by name — verified
|
|
|
|
Registering `name: "read"` replaces the built-in in the registry: the probe
|
|
showed `read[cli]` instead of `read[builtin]` while other built-ins kept
|
|
`[builtin]`. This is the supported way to give an agent a path-restricted
|
|
`read` while still satisfying the requirement in §1.
|
|
|
|
`memo-guard.ts` in the memo-inbox scenario has used this in production.
|
|
|
|
## 7. Only tools with `promptSnippet` appear in the human-readable tool list
|
|
|
|
`dist/core/system-prompt.js`: "A tool appears in Available tools only when the
|
|
caller provides a one-line snippet."
|
|
|
|
The tool *schemas* are always sent through the provider's tool-calling API, so
|
|
an omitted `promptSnippet` does not break invocation — but the model loses the
|
|
prose overview. Always set `promptSnippet`, and `promptGuidelines` where useful.
|
|
|
|
## 8. `.pi/SYSTEM.md` replaces the default prompt; the replacement branch omits tools and guidelines
|
|
|
|
Default prompt (`system-prompt.js:82`) begins:
|
|
|
|
> You are an expert coding assistant operating inside pi, a coding agent harness.
|
|
> You help users by reading files, executing commands, editing code, and writing new files.
|
|
|
|
and ends with a block of **absolute paths to pi's own README / docs / examples**
|
|
plus "read the docs and examples, and follow .md cross-references before
|
|
implementing".
|
|
|
|
For a non-coding agent this is not merely noise: it is a ready-made escalation
|
|
path for prompt injection.
|
|
|
|
The `customPrompt` branch (`system-prompt.js:13-33`) concatenates only:
|
|
|
|
```
|
|
SYSTEM.md → APPEND_SYSTEM.md → <project_context>(AGENTS.md) → skills section → cwd
|
|
```
|
|
|
|
It does **not** include `toolsList` or `Guidelines`. A replacement `SYSTEM.md`
|
|
must therefore carry its own tool overview and output discipline.
|
|
|
|
`.pi/SYSTEM.md` lives under `.pi/`, so it requires **project trust** — pass
|
|
`--approve` in non-interactive modes.
|
|
|
|
Probe evidence:
|
|
|
|
| Combination | coding-assistant text | pi-docs block | SYSTEM.md applied | prompt length |
|
|
|---|---|---|---|---|
|
|
| isolation flags only | true | true | false | 2619 |
|
|
| `+ .pi/SYSTEM.md + --approve` | **false** | **false** | **true** | **960** |
|
|
|
|
## 9. Context files are loaded from every parent directory, and `AGENTS.override.md` does not stop that
|
|
|
|
Load order: `~/.pi/agent/AGENTS.md`, then each parent directory walking up from
|
|
cwd, then cwd.
|
|
|
|
`AGENTS.override.md` replaces `AGENTS.md`/`CLAUDE.md` **for its own directory
|
|
only**. Parent directories still layer normally — the docs say so explicitly
|
|
("Context files from other directories still layer normally") and it is easy to
|
|
get this wrong.
|
|
|
|
Probed: with `AGENTS.override.md` present in the workspace and a marker file at
|
|
`/tmp/AGENTS.md`, the marker **still appeared** in the system prompt.
|
|
|
|
| Configuration | parent `/tmp/AGENTS.md` in prompt |
|
|
|---|---|
|
|
| workspace has `AGENTS.override.md` | **yes** |
|
|
| `--no-context-files` (`-nc`) | no |
|
|
|
|
So a stray `~/AGENTS.md` or `~/pi-workspaces/AGENTS.md` silently contaminates
|
|
every scenario rooted below it, and an override file will not save you. Neither
|
|
existed on this host as of 2026-08-27, but nothing prevents one from appearing.
|
|
|
|
**The only way to make the personality deterministic is `-nc`.** Since that also
|
|
drops the workspace's own context file, the durable role content has to move into
|
|
`.pi/SYSTEM.md` and `.pi/APPEND_SYSTEM.md`, which are system-prompt files rather
|
|
than context files and are therefore unaffected by `-nc`.
|
|
|
|
Verified combination — no coding-assistant framing, no pi-docs block, own
|
|
identity and role text present, parent pollution absent, only the scenario's own
|
|
skill listed:
|
|
|
|
```
|
|
--no-builtin-tools --no-extensions -e <ext> --no-skills --skill <dir>
|
|
--no-prompt-templates --no-themes --approve -nc
|
|
+ .pi/SYSTEM.md + .pi/APPEND_SYSTEM.md
|
|
```
|
|
|
|
Note also that `cwd` is what anchors this discovery: launching pi from the wrong
|
|
working directory silently drops `.pi/SYSTEM.md` and every workspace context
|
|
file. A probe that forgot `cwd` produced a 2601-character prompt with the
|
|
coding-assistant persona intact; with the correct `cwd` it produced 4082
|
|
characters with the persona replaced.
|
|
|
|
## 10. Project trust gates `.pi/`, and CLI `-e` bypasses it
|
|
|
|
Trust is required when the directory contains `.pi/settings.json`,
|
|
`.pi/{extensions,skills,prompts,themes}`, `.pi/SYSTEM.md`,
|
|
`.pi/APPEND_SYSTEM.md`, or a project `.agents/skills`. A bare `.pi` does not
|
|
count.
|
|
|
|
Always loaded regardless of trust: `AGENTS.override.md`, `AGENTS.md`,
|
|
`CLAUDE.md`, user/global extensions, and **CLI `-e` extensions** — the last one
|
|
is deliberate so they can handle the `project_trust` event.
|
|
|
|
Non-interactive modes (`-p`, `--mode json`, `--mode rpc`) never prompt; they use
|
|
`defaultProjectTrust` (`ask` default / `always` / `never`) unless `--approve` /
|
|
`--no-approve` overrides for the run.
|
|
|
|
**Design consequence:** loading a scenario extension with an absolute
|
|
`-e /path/to/ext.ts` avoids the trust question entirely, which is the most
|
|
robust option for a systemd-managed gateway.
|
|
|
|
## 10b. `PI_CODING_AGENT_DIR` isolates the agent directory — but not `~/.agents/skills`
|
|
|
|
```
|
|
PI_CODING_AGENT_DIR Override the config directory; default is ~/.pi/agent
|
|
```
|
|
|
|
This is the strongest isolation lever available, and it is stronger than the
|
|
`--no-*` flags because it repoints **credentials and trust** as well as
|
|
resources. The `pi-grok` scenario on this host uses it:
|
|
|
|
```sh
|
|
export PI_CODING_AGENT_DIR="$PI_GROK_HOME/.pi-agent"
|
|
```
|
|
|
|
Measured, with a marker extension planted in both directories and no `--no-*`
|
|
flags at all:
|
|
|
|
| Resource | default agent dir | `PI_CODING_AGENT_DIR=<iso>` |
|
|
|---|---|---|
|
|
| `<dir>/extensions/*` | `DEFAULT_EXT_LOADED` | `ISO_EXT_LOADED` — **isolated** |
|
|
| `<dir>/skills/*` | *(none)* | `iso-skill` present — **isolated** |
|
|
| `~/.agents/skills/*` | `find-skills, modsearch, summarize` | `find-skills, modsearch, summarize` — **still leaks** |
|
|
|
|
So it isolates `settings.json`, `models.json`, `auth.json`, `trust.json`,
|
|
`extensions/`, `skills/`, `prompts/` and `themes/` **under the agent directory**,
|
|
but `~/.agents/skills/` is a separate discovery root that it does not touch.
|
|
|
|
Practical consequences:
|
|
|
|
* Use `PI_CODING_AGENT_DIR` per scenario when scenarios should not share
|
|
provider credentials, trust decisions or model defaults. It is the only way to
|
|
stop one scenario's `auth.json` from being readable by another's agent.
|
|
* It does **not** replace `--no-skills`. Keep the loading flags as well.
|
|
* A companion variable exists: `PI_CODING_AGENT_SESSION_DIR`, overridden by
|
|
`--session-dir`.
|
|
|
|
## 10c. `--append-system-prompt` accepts a file path
|
|
|
|
The help text says "Append text **or file contents**". `pi-grok` relies on this:
|
|
|
|
```sh
|
|
--append-system-prompt "$PI_GROK_HOME/AGENTS.md"
|
|
```
|
|
|
|
This is a third way to inject durable role text, alongside
|
|
`.pi/APPEND_SYSTEM.md` and `AGENTS.md`. Unlike a context file it is immune to the
|
|
parent-directory walk, and unlike `.pi/APPEND_SYSTEM.md` it needs no project
|
|
trust. Useful when the role text must live outside the workspace.
|
|
|
|
## 11. User-global resources leak into every scenario
|
|
|
|
Probed with no isolation flags, from an unrelated workspace, the skills list was:
|
|
|
|
```
|
|
PROBE_SKILLNAMES=["find-skills","modsearch","summarize"]
|
|
```
|
|
|
|
Those come from `~/.agents/skills/`. `find-skills` in particular instructs the
|
|
agent to discover and install further skills — an unwanted capability surface
|
|
for a narrow-purpose agent. `~/.pi/agent/extensions/*.ts` leak the same way.
|
|
|
|
With `--no-skills --skill <abs>` the list becomes exactly `["probe-skill"]`.
|
|
|
|
## 12. No output-schema flag; use a terminating tool with constrained sampling
|
|
|
|
There is no `--output-schema` / `--response-format`. Structured output is
|
|
achieved with a tool whose `parameters` is the desired schema:
|
|
|
|
```typescript
|
|
{
|
|
name: "emit_result",
|
|
parameters: <schema>,
|
|
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
|
async execute(_id, params) {
|
|
return { content: [...], details: params, terminate: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
`ConstrainedSamplingConfig`:
|
|
|
|
```typescript
|
|
| { type: "json_schema"; strict: "prefer" | "require" }
|
|
| { type: "grammar"; variants: Partial<Record<"openai_lark" | "openai_regex", string>> }
|
|
```
|
|
|
|
`strict: "prefer"` falls back gracefully; `"require"` fails the request if the
|
|
provider cannot honour it. `terminate: true` ends the turn only when *every*
|
|
finalized tool result in the batch sets it.
|
|
|
|
This replaces regex-scraping JSON out of prose.
|
|
|
|
## 13. Tools must truncate their own output
|
|
|
|
Built-in caps are **50 KB** and **2000 lines**. Helpers exported for this:
|
|
`truncateHead`, `truncateTail`, `truncateLine`, `formatSize`,
|
|
`DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES`.
|
|
|
|
Errors: **throw** from `execute` to set `isError: true`. Returning an
|
|
error-shaped object does not mark the call failed.
|
|
|
|
`AgentToolResult.content` goes to the model; `details` is persisted in the
|
|
session but **not** sent to the model.
|
|
|
|
## 14. Per-call gating hook
|
|
|
|
```typescript
|
|
pi.on("tool_call", async (event) => {
|
|
if (!ALLOWED.includes(event.toolName)) {
|
|
return { block: true, reason: "…" };
|
|
}
|
|
});
|
|
```
|
|
|
|
`ToolCallEventResult = { block?, reason?, terminate? }`. To rewrite arguments,
|
|
mutate `event.input` in place. Later handlers observe earlier mutations and no
|
|
re-validation happens afterwards.
|
|
|
|
Combined with `pi.setActiveTools()` on `session_start` and
|
|
`resources_discover`, this gives two independent layers of capability control
|
|
that survive a mistake in the CLI flags.
|
|
|
|
## 15. RPC mode is strict JSONL and is meant to be long-lived
|
|
|
|
`--mode rpc`: commands as JSON objects on stdin, one per line; responses and
|
|
events as JSON lines on stdout.
|
|
|
|
> Split records on `\n` only. Accept optional `\r\n` by stripping a trailing
|
|
> `\r`. Node `readline` is **not** protocol-compliant because it also splits on
|
|
> `U+2028`/`U+2029`, which are valid inside JSON strings.
|
|
|
|
33 commands, including `prompt`, `steer`, `follow_up`, `abort`, `new_session`,
|
|
`get_state`, `set_model`, `set_thinking_level`, `compact`,
|
|
`set_auto_compaction`, `get_session_stats`, `switch_session`, `fork`,
|
|
`get_entries`.
|
|
|
|
`message_update` carries **deltas only** — no cumulative `message`, no
|
|
`partial`. Treat `message_end.message` as authoritative.
|
|
|
|
There is **no** command to inject a tool result from the host. Host-side
|
|
capabilities must be exposed as an extension tool that calls back out.
|
|
|
|
Probing `get_state` alone starts the agent, fires `session_start`, and exits
|
|
without ever contacting the model — a zero-cost way to inspect the resolved
|
|
system prompt and tool set.
|
|
|
|
## 16. Usage and cost are recorded per assistant message
|
|
|
|
Every `AssistantMessage` carries:
|
|
|
|
```typescript
|
|
Usage { input, output, cacheRead, cacheWrite, totalTokens,
|
|
cost: { input, output, cacheRead, cacheWrite, total } }
|
|
```
|
|
|
|
`ToolResultMessage`, `CompactionEntry` and `BranchSummaryEntry` carry an
|
|
optional `usage` for nested LLM work. Live equivalent: RPC
|
|
`get_session_stats`, which also returns
|
|
`contextUsage { tokens, contextWindow, percent }`.
|
|
|
|
Observability therefore needs no extra instrumentation — read the session JSONL
|
|
or subscribe to `message_update`.
|
|
|
|
## 17. Sessions never rotate or expire
|
|
|
|
`--session-id <id>` is an idempotent "use this project session, create if
|
|
missing" primitive (mutually exclusive with `--session`, `--continue`,
|
|
`--resume`; validated against `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`).
|
|
|
|
There is no built-in TTL, rotation, size cap or pruning. Files grow
|
|
monotonically until deleted by hand.
|
|
|
|
## 18. Auto-compaction rarely triggers on large-context models
|
|
|
|
Condition: `contextTokens > contextWindow - reserveTokens`.
|
|
|
|
```json
|
|
{ "compaction": { "enabled": true, "reserveTokens": 16384, "keepRecentTokens": 20000 } }
|
|
```
|
|
|
|
With a 1,050,000-token context window the threshold is ~1.03 M tokens, so
|
|
latency and cost degrade for a very long time before compaction ever fires.
|
|
A gateway must implement its own rotation policy — see
|
|
[`gateway-patterns.md`](gateway-patterns.md).
|
|
|
|
## 19. `allowed-tools` in SKILL.md frontmatter is not enforced
|
|
|
|
`dist/core/skills.js:loadSkillFromFile` consumes only `name`, `description` and
|
|
`disable-model-invocation`. `allowed-tools` is documented as experimental and is
|
|
**not read** in 0.84.3.
|
|
|
|
The memo-inbox scenario declares `allowed-tools:` in its `SKILL.md`; the real
|
|
enforcement is the `ALLOWED_TOOLS` array plus `setActiveTools` and the
|
|
`tool_call` hook inside `memo-guard.ts`. Do not rely on the frontmatter field
|
|
for security.
|
|
|
|
## 20. There is no sandbox
|
|
|
|
`docs/security.md`: "Pi does not include a built-in sandbox. Built-in tools can
|
|
read files, write files, edit files, and run shell commands with the permissions
|
|
of the pi process. Extensions are TypeScript modules that run with the same
|
|
permissions."
|
|
|
|
A path-restricted `read` override is an application-level boundary, not a kernel
|
|
one. Real isolation requires bubblewrap / container / micro-VM; pi ships
|
|
`examples/extensions/sandbox/` (bubblewrap, `.pi/sandbox.json`) and
|
|
`examples/extensions/gondolin/` (micro-VM) as starting points.
|
|
|
|
---
|
|
|
|
## Re-verification
|
|
|
|
```bash
|
|
cd docs/evidence/probe-harness
|
|
./collect-evidence.sh > ../$(date -u +%Y-%m-%d)-isolation-probe.md
|
|
```
|
|
|
|
The harness costs zero model tokens: it sends a single `get_state` over RPC and
|
|
reads the resolved system prompt from `session_start`.
|