Two findings that changed the plan rather than confirming it:
23. Skills require a tool literally named `read`. Curator's tools are all
domain-specific, so every --skill argument was discarded in silence. The
planned split into curator-core / video-arr / books-ingest was inert before
it was written; the policy stays in APPEND_SYSTEM.md. memo-inbox is
unaffected because it registers a restricted `read` override, which is why
the earlier note generalised wrongly from it.
24. A long-lived session is worth far more than the startup it saves: 99.97% of
input read from cache on a continuing conversation against 0% on a new one.
That is what makes the generated tool list necessary rather than merely
tidy -- anything varying at the front of the prompt destroys it -- and it
makes rotation a cost to be bounded rather than applied eagerly.
profile.toml now describes the phase-3 configuration that is actually deployed,
including that the empty `skills` list is a finding and not an oversight.
pi_rpc gains --system-prompt support and no longer guesses whether a `read` tool
will exist; extension_registers_read has to be stated.
harness-layering.md records what transfers from a widely-shared account of
building a personal coding harness on pi, and what does not. The layering frame
holds and the cache-hit figure was the useful part. Its central recommendation --
installing third-party packages -- is disqualifying for an unattended agent
holding tracker credentials, and its discipline layer (AGENTS.md) is precisely
what we block, because it is discovered from every parent directory.
23 KiB
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 inevidence/probe-harness/. Results: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:
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:
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:
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:
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_DIRper scenario when scenarios should not share provider credentials, trust decisions or model defaults. It is the only way to stop one scenario'sauth.jsonfrom 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:
--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:
{
name: "emit_result",
parameters: <schema>,
constrainedSampling: { type: "json_schema", strict: "prefer" },
async execute(_id, params) {
return { content: [...], details: params, terminate: true };
}
}
ConstrainedSamplingConfig:
| { 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
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
\nonly. Accept optional\r\nby stripping a trailing\r. Nodereadlineis not protocol-compliant because it also splits onU+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:
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.
{ "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.
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.
21. An extension that fails to import is silent, and the agent then invents facts
Two failure modes, only one of which is reported:
| condition | exit | stderr |
|---|---|---|
--extension path does not exist |
1 | Failed to load extension ... plus a hint |
| extension exists but throws while importing | 0 | empty |
The second registers no tools and says nothing. Measured with an extension whose
only defect was importing ExtensionAPI from @getpi/pi instead of
@earendil-works/pi-coding-agent.
What the agent did with no tools, asked which versions of a series were in the library:
《权力的游戏》(Game of Thrones)1 个版本:
- 4K 实例,已跟踪
- 8 季,共 73 集,文件已齐(73/73)
- 画质:2160p WEB-DL
- 占用空间:624.7 GB
Every line is fabricated, and the size happens to be close to the real figure. A later run of the same prompt said it had no data at all. So the failure is not only silent but inconsistent: sometimes a refusal, sometimes a confident and detailed invention.
Therefore: do not treat process exit status as evidence that the tools
loaded. Curator detects activation at the bridge, which the extension must call
to obtain its tool list, and refuses to proceed without it
(curator/agent_api.py, wait_for_activation).
22. --system-prompt suppresses the tool list, so promptSnippet is inert
dist/core/system-prompt.js builds toolsList from promptSnippet and
guidelines from promptGuidelines — but the customPrompt branch returns
before either is assembled:
if (customPrompt) {
let prompt = customPrompt;
if (appendSection) prompt += appendSection;
// context files, then skills (only when a `read` tool is active)
prompt += `\nCurrent working directory: ${promptCwd}\n`;
return prompt; // toolsList and guidelines never appear
}
The tools remain callable: their schemas still go to the provider as tool definitions. The model simply is not told in prose that it has them.
Measured effect on the same question, four runs, extension loading correctly:
| runs | behaviour |
|---|---|
| 1 | called query_library, answered from the result |
| 3 | called nothing, answered "I was not given any library results" |
After writing the tool list into the system prompt itself, four different prompts each used the right tools and none answered unaided.
Therefore: an agent using --system-prompt must enumerate its own tools in
that prompt. Curator generates the section from the same specs the bridge serves
(contracts.render_tool_prose) and scripts/verify-generated.sh fails the
commit when the two drift.
This also means promptGuidelines cannot be relied on for safety-relevant
instructions under --system-prompt; they have to be in the prompt text.
23. Skills need a tool literally named read, so a domain-only agent cannot use them
Section 1 recorded that --no-tools disables skills. The real rule is narrower and
worse: formatSkillsForPrompt runs only when a tool named read is active.
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (customPromptHasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}
Measured with a probe registering three tools and loading one skill:
| active tools | --system-prompt |
skills section | skill names |
|---|---|---|---|
probe_read, query_library, … |
yes | true | […, curator-core] |
probe_read, query_library, … |
no | true | […, curator-core] |
query_library, lookup_online, counts |
yes | false | [] |
query_library, lookup_online, counts |
no | false | [] |
So it is not about --system-prompt and not about --no-tools. An agent whose
tools are all domain-specific gets nothing from --skill, silently.
memo-inbox is unaffected because it registers a path-restricted read override.
Curator has no read and no reason to invent one, so its --skill arguments were
discarded and the plan's split into curator-core / video-arr / books-ingest
was inert before it was written. The policy stays in APPEND_SYSTEM.md, which is
unconditional.
PiLaunchConfig._read_reachable used to assume no_builtin_tools implied an
extension supplying read. It no longer guesses: extension_registers_read must
be set explicitly, and loading skills without it logs a warning.
A side benefit of not using skills: on-demand injection varies the prompt, and a varying prefix defeats provider prompt caching. See section 24.
24. A long-lived session is worth far more than the process startup it saves
Measured on Curator's conversation path, same question, same model:
| turn | cache read / billed input |
|---|---|
| first turn of a new session | 0% |
| continuing session | 99.97% |
The saving is not the ~1-2 s of process startup, it is that the whole system prompt and prior history are re-read from cache instead of re-charged.
The practical constraints that follow:
- Nothing may vary at the front of the prompt. The tool list is generated into the system prompt once (section 22) rather than injected per turn, and per-request restatements of "which adapters exist" were removed.
- Rotation is a cost. Each rotation resets the cache, so rotate on explicit
bounds (
rotate_after_prompts,rotate_after_messages) rather than eagerly. - Session files must persist. Curator keys the session id on a uuid5 of the chat id, so a service restart resumes the same session and keeps the cache warm.
- Idle processes must still be reclaimed. A process per chat that is never
stopped is 100-200 MB and several tasks; the set has to be swept on a TTL or it
grows until
MemoryMaxorTasksMaxproduces an unexplained failure to start a new conversation.
Re-verification
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.