@counterposition/skill-pi 0.87.1 → 1.0.0

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.
package/README.md CHANGED
@@ -28,15 +28,16 @@ If you develop inside this repo, Pi already loads the skill via `.pi/settings.js
28
28
 
29
29
  ## What this covers
30
30
 
31
- Pi's architecture and package layout. Skill authoring, discovery, and routing. Extensions, tools, and UI hooks. Settings, models, providers, and packages. Sessions, forks, and compaction. The SDK and RPC interface.
31
+ Pi's architecture and package layout. Skill authoring, discovery, and routing. Extensions, tools, and UI hooks. MCP servers, codemode, and nested tool calls. Settings, models, providers, classifiers, and packages. Sessions, forks, and compaction. The SDK and RPC interface. Checked against Pi 1.0.0.
32
32
 
33
33
  The reference material lives in `references/` as task-specific documents — not a single monolith, but pieces sized for injection into a conversation when relevant.
34
34
 
35
35
  ## Files
36
36
 
37
- | Path | Purpose |
38
- | ------------------ | ------------------------------------- |
39
- | `SKILL.md` | Trigger rules and routing description |
40
- | `references/` | Task-specific Pi reference documents |
41
- | `evals/evals.json` | Example evaluation cases |
42
- | `LICENSE.md` | GPLv3 license text |
37
+ | Path | Purpose |
38
+ | ------------------ | --------------------------------------------- |
39
+ | `SKILL.md` | Trigger rules and routing description |
40
+ | `references/` | Task-specific Pi reference documents |
41
+ | `examples/` | Extension examples that type-check against Pi |
42
+ | `evals/evals.json` | Example evaluation cases |
43
+ | `LICENSE.md` | GPLv3 license text |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@counterposition/skill-pi",
3
- "version": "0.87.1",
3
+ "version": "1.0.0",
4
4
  "description": "Pi coding agent reference skill — extensions, settings, providers, SDK/RPC, packages, and .pi/ config",
5
5
  "keywords": [
6
6
  "ai-skill",
@@ -25,9 +25,18 @@
25
25
  "LICENSE.md",
26
26
  "package.json"
27
27
  ],
28
+ "type": "module",
28
29
  "publishConfig": {
29
30
  "access": "public"
30
31
  },
32
+ "devDependencies": {
33
+ "@earendil-works/pi-ai": "^1.0.0",
34
+ "@earendil-works/pi-coding-agent": "^1.0.0",
35
+ "@types/node": "^25.5.0",
36
+ "typebox": "^1.3.16",
37
+ "typescript": "^6.0.2",
38
+ "vitest": "^4.1.2"
39
+ },
31
40
  "engines": {
32
41
  "node": ">=24"
33
42
  },
@@ -40,9 +49,11 @@
40
49
  "lint": "pnpm run lint:docs",
41
50
  "lint:docs": "markdownlint-cli2 \"README.md\"",
42
51
  "lint:docs:fix": "markdownlint-cli2 --fix \"README.md\"",
43
- "format": "oxfmt \"README.md\" \"package.json\"",
44
- "format:check": "oxfmt \"README.md\" \"package.json\" --check",
45
- "check": "pnpm run lint && pnpm run format:check && pnpm run pack:check",
52
+ "format": "oxfmt \"README.md\" \"package.json\" \"tests\" \"vitest.config.ts\"",
53
+ "format:check": "oxfmt \"README.md\" \"package.json\" \"tests\" \"vitest.config.ts\" --check",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "check": "pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm run test && pnpm run pack:check",
46
57
  "pack:check": "pnpm pack --pack-destination ../../.pack"
47
58
  }
48
59
  }
@@ -28,15 +28,16 @@ If you develop inside this repo, Pi already loads the skill via `.pi/settings.js
28
28
 
29
29
  ## What this covers
30
30
 
31
- Pi's architecture and package layout. Skill authoring, discovery, and routing. Extensions, tools, and UI hooks. Settings, models, providers, and packages. Sessions, forks, and compaction. The SDK and RPC interface.
31
+ Pi's architecture and package layout. Skill authoring, discovery, and routing. Extensions, tools, and UI hooks. MCP servers, codemode, and nested tool calls. Settings, models, providers, classifiers, and packages. Sessions, forks, and compaction. The SDK and RPC interface. Checked against Pi 1.0.0.
32
32
 
33
33
  The reference material lives in `references/` as task-specific documents — not a single monolith, but pieces sized for injection into a conversation when relevant.
34
34
 
35
35
  ## Files
36
36
 
37
- | Path | Purpose |
38
- | ------------------ | ------------------------------------- |
39
- | `SKILL.md` | Trigger rules and routing description |
40
- | `references/` | Task-specific Pi reference documents |
41
- | `evals/evals.json` | Example evaluation cases |
42
- | `LICENSE.md` | GPLv3 license text |
37
+ | Path | Purpose |
38
+ | ------------------ | --------------------------------------------- |
39
+ | `SKILL.md` | Trigger rules and routing description |
40
+ | `references/` | Task-specific Pi reference documents |
41
+ | `examples/` | Extension examples that type-check against Pi |
42
+ | `evals/evals.json` | Example evaluation cases |
43
+ | `LICENSE.md` | GPLv3 license text |
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: pi
3
- description: "Provides Pi-specific guidance for extensions, skills, `.pi/` config, `settings.json`, `models.json`, packages, providers, themes, SDK/RPC, sessions, and compaction in Pi, the coding agent. Use when the user mentions Pi or Pi-specific terms such as `.pi/`, `SKILL.md`, `createAgentSession()`, `thinkingLevel`, `session_before_compact`, `pi-ai`, `pi-tui`, `pi-agent-core`, or `pi-coding-agent`. Not for Raspberry Pi hardware, the math constant, or unrelated generic tooling."
3
+ description: "Provides Pi-specific guidance for extensions, skills, `.pi/` config, `settings.json`, `models.json`, `mcp.json`, MCP servers, codemode, packages, providers, themes, SDK/RPC, sessions, and compaction in Pi, the coding agent. Use when the user mentions Pi or Pi-specific terms such as `.pi/`, `SKILL.md`, `createAgentSession()`, `thinkingLevel`, `session_before_compact`, `ctx.executeTool`, `pi-ai`, `pi-tui`, `pi-agent-core`, or `pi-coding-agent`. Not for Raspberry Pi hardware, the math constant, or unrelated generic tooling."
4
4
  license: GPLv3
5
5
  compatibility: "Pi-specific guidance for agents that support Agent Skills"
6
6
  ---
7
7
 
8
8
  # Pi Coding Agent
9
9
 
10
- Pi is a minimal terminal coding harness. Default tools: `read`, `bash`, `edit`, `write`; `grep`, `find`, `ls`, and the Windows `powershell` tool (Pi 0.84.3) are opt-in via the `defaultTools` setting or `--tools`. Modes: interactive (`pi`), print (`pi -p`), JSON (`--mode json`), RPC (`--mode rpc`), or embedded (`createAgentSession()`). Sub-agents, plan mode, permission flows, and MCP are intentionally left to extensions and Pi packages. Skill synced with Pi 0.87.1.
10
+ Pi is a minimal terminal coding harness. Default tools: `read`, `bash`, `edit`, `write`; `grep`, `find`, `ls`, and the Windows `powershell` tool are opt-in via the `defaultTools` setting or `--tools`. Built-in extensions (Pi 0.99.0) add MCP servers, the `codemode` tool (model-written JavaScript that calls other tools), and `tool_search`. `codemode` and `tool_search` start inactive: enable them with `defaultTools`/`--tools`, or let MCP activate them when a server needs them. Modes: interactive (`pi`), print (`pi -p`), JSON (`--mode json`), RPC (`--mode rpc`), or embedded (`createAgentSession()`). Sub-agents, plan mode, and permission flows are intentionally left to extensions and Pi packages. Skill synced with Pi 1.0.0.
11
11
 
12
12
  Pi's philosophy: **adapt Pi to your workflows, not the other way around**.
13
13
 
@@ -18,12 +18,14 @@ Core packages on npm (source: [github.com/earendil-works/pi](https://github.com/
18
18
  | Package | Purpose |
19
19
  |---------|---------|
20
20
  | `@earendil-works/pi-ai` | Unified LLM API across 20+ providers |
21
- | `@earendil-works/pi-agent-core` | Agent runtime with tool execution and state |
21
+ | `@earendil-works/pi-agent-core` | `Agent`, the agent loop, and the proxy stream (Pi 1.0.0 removed the experimental harness, sessions, and its `./node`/`./harness/*` subpaths) |
22
22
  | `@earendil-works/pi-tui` | Terminal UI components |
23
23
  | `@earendil-works/pi-coding-agent` | CLI, extensions, skills, sessions, settings (also exports `./client` for remote sessions and `./rpc-entry`) |
24
- | `@earendil-works/pi-web-ui` | Web components for chat interfaces |
24
+ | `@earendil-works/pi-codemode` | QuickJS sandbox that runs model-written JavaScript against injected tools (Pi 0.99.0) |
25
+ | `@earendil-works/pi-mcp` | Standalone MCP client: stdio and streamable HTTP, OAuth (Pi 0.99.0) |
25
26
  | `@earendil-works/pi-client` / `@earendil-works/pi-protocol` | Experimental remote-session client and wire protocol (Pi 0.84.0) |
26
27
  | `@earendil-works/pi-telemetry` | Vendor-neutral telemetry contracts (Pi 0.84.0) |
28
+ | `@earendil-works/pi-durable` | Experimental durable agent harness: conversations, turns, and tool calls committed to storage and resumed after a crash (Pi 1.0.0; replaces the pi-agent-core harness) |
27
29
 
28
30
  ## File System Layout
29
31
 
@@ -32,6 +34,7 @@ Core packages on npm (source: [github.com/earendil-works/pi](https://github.com/
32
34
  ├── settings.json # Global settings
33
35
  ├── auth.json # Credentials (0600 perms)
34
36
  ├── models.json # Custom provider/model definitions
37
+ ├── mcp.json # MCP servers (mcp-auth.json holds their OAuth tokens)
35
38
  ├── keybindings.json # Keyboard shortcuts
36
39
  ├── extensions/ # Auto-discovered
37
40
  ├── skills/ # Auto-discovered
@@ -42,6 +45,7 @@ Core packages on npm (source: [github.com/earendil-works/pi](https://github.com/
42
45
  <project>/
43
46
  └── .pi/ # Project-local config (overrides global)
44
47
  ├── settings.json
48
+ ├── mcp.json # Project MCP servers (read only once trusted)
45
49
  ├── extensions/
46
50
  ├── skills/
47
51
  ├── prompts/
@@ -53,6 +57,7 @@ Core packages on npm (source: [github.com/earendil-works/pi](https://github.com/
53
57
 
54
58
  - **Extensions** — TypeScript modules with full system access. Hook into Pi's lifecycle to register tools, intercept calls, add commands, build UI. → `references/extensions.md`
55
59
  - **Skills** — Markdown capability packages (`SKILL.md` + frontmatter) following the Agent Skills standard. Pi loads names + descriptions into the system prompt; bodies load on demand. → `references/skills.md`
60
+ - **MCP & codemode** — Built-in MCP client configured by `mcp.json` or `pi.registerMcpServer()`; server tools default to `codemode` exposure, reachable from `codemode` scripts rather than declared to the model. Codemode scripts get a tool's `structuredContent` when it declares `outputSchema`, and their nested calls still reach `tool_call` gates. → `references/mcp.md` and `references/codemode.md`
56
61
  - **Settings** — Hierarchical JSON: project `.pi/settings.json` merges over global `~/.pi/agent/settings.json`. → `references/settings.md`
57
62
  - **Packages** — Bundles of extensions/skills/prompts/themes via npm, git, or local paths. Installed with `pi install`. → `references/packages.md`
58
63
  - **Project trust** — Pi 0.79+ asks before loading project-local settings, resources, and packages; decisions persist in `~/.pi/agent/trust.json`. `--approve`/`--no-approve` override per run; `defaultProjectTrust` sets the non-interactive fallback. → `references/settings.md`
@@ -66,11 +71,13 @@ Core packages on npm (source: [github.com/earendil-works/pi](https://github.com/
66
71
  | Task | Reference |
67
72
  |------|-----------|
68
73
  | Writing an extension (tools, commands, events, UI) | `references/extensions.md` |
74
+ | Connecting MCP servers (`mcp.json`, `pi mcp`, exposure) | `references/mcp.md` |
75
+ | Codemode scripts, tool exposure, nested tool calls, permission gates for them | `references/codemode.md` |
69
76
  | Creating a skill | `references/skills.md` |
70
77
  | Configuring Pi (settings, env vars, CLI flags) | `references/settings.md` |
71
78
  | Building a shareable package | `references/packages.md` |
72
79
  | Embedding Pi programmatically | `references/sdk.md` |
73
- | Adding LLM providers or models | `references/providers.md` |
74
- | Looking for a recipe or pattern | `references/patterns.md` |
80
+ | Adding LLM providers or models, classifiers, image models, virtual models | `references/providers.md` |
81
+ | Looking for a recipe or pattern | `references/patterns.md` (compilable examples in `examples/`) |
75
82
 
76
- For exact CLI flags run `pi --help` or read the relevant reference. Resource flags (`--no-extensions`, `--no-skills`, `--no-context-files`, `--no-builtin-tools`, etc.) are documented in `references/settings.md`.
83
+ For exact CLI flags run `pi --help` or read the relevant reference. Resource flags (`--no-extensions`, `--no-skills`, `--no-context-files`, `--no-builtin-tools`, etc.) are documented in `references/settings.md`; `--no-extensions` also disables the built-in extensions, and `-e builtin:<name>` loads one back.
@@ -27,6 +27,7 @@
27
27
  "Provides models.json with Ollama baseUrl containing localhost:11434",
28
28
  "Uses api type openai-completions (correct for Ollama)",
29
29
  "Includes model entry with id llama3.2",
30
+ "Writes models.json providers as an object keyed by provider id (e.g. \"ollama\"), not an array",
30
31
  "Provides settings.json with compaction.enabled set to false",
31
32
  "Sets sessionDir to ~/pi-sessions",
32
33
  "Correct file paths: ~/.pi/agent/models.json and ~/.pi/agent/settings.json"
@@ -45,6 +46,86 @@
45
46
  "SKILL.md uses relative paths to reference files",
46
47
  "Body routes to the correct reference doc based on the user's framework choice"
47
48
  ]
49
+ },
50
+ {
51
+ "id": 4,
52
+ "prompt": "Add the GitHub MCP server (https://api.githubcopilot.com/mcp/, token in GITHUB_TOKEN) to Pi for all my projects. It has ~90 tools, so keep them out of the model's tool list, but make search_code directly callable. Check that it works.",
53
+ "expected_output": "An entry in ~/.pi/agent/mcp.json under mcpServers with the url, an Authorization header referencing ${GITHUB_TOKEN}, a non-direct server exposure (codemode or deferred), and toolExposure making search_code direct; then pi mcp list to verify and /reload for a running session.",
54
+ "files": [],
55
+ "assertions": [
56
+ "Writes the server to ~/.pi/agent/mcp.json (global), not settings.json and not the project .pi/mcp.json",
57
+ "Uses the mcpServers object with a url field (streamable HTTP), not an sse type",
58
+ "Keeps the secret out of the file with \"Authorization\": \"Bearer ${GITHUB_TOKEN}\" (or pi mcp add --bearer-token-env-var GITHUB_TOKEN)",
59
+ "Sets server exposure to codemode or deferred (or the codemode-deferred alias) rather than direct",
60
+ "Uses toolExposure with \"search_code\": \"direct\"",
61
+ "Runs pi mcp list to verify the connection",
62
+ "Tells the user to /reload or start a new session so a running session connects",
63
+ "Does not tell the user to install an MCP extension or package, since MCP is built in"
64
+ ]
65
+ },
66
+ {
67
+ "id": 5,
68
+ "prompt": "My Pi permission extension asks before every bash call. It works for direct calls, but when the model runs bash from a codemode script the approval lookup fails because my code searches the last assistant message for event.toolCallId. Fix the extension so codemode calls are gated too.",
69
+ "expected_output": "A tool_call handler that judges each call from the event itself, recognises nested calls by event.parentToolCallId (ids like <parent>/<n>), does not require the id to exist in the assistant message, does not auto-approve children because the codemode call was approved, and blocks when there is no UI.",
70
+ "files": [],
71
+ "assertions": [
72
+ "Keeps using the tool_call event and checks event.toolName === 'bash' (or isToolCallEventType('bash', event))",
73
+ "Explains that nested calls carry event.parentToolCallId and have ids of the form <parent id>/<n> that never appear in the transcript",
74
+ "Stops requiring the tool call id to be found in the last assistant message for nested calls",
75
+ "Reads the command from event.input for the nested call instead of parsing the codemode script",
76
+ "Does not approve a nested call just because the parent codemode call was approved",
77
+ "Does not rely on the nestedCalls record on the tool result for approval",
78
+ "Checks ctx.hasUI and returns { block: true, reason } when no UI is available",
79
+ "Notes that a blocked nested call makes the script's tools.bash() promise reject"
80
+ ]
81
+ },
82
+ {
83
+ "id": 6,
84
+ "prompt": "I have a Pi extension tool `list_issues` that returns a text table. When the model calls it from codemode, the script gets a string it has to parse. Change the tool so codemode scripts get the issue objects, and keep the model's view readable.",
85
+ "expected_output": "The tool declares outputSchema (TypeBox) and returns structuredContent matching it on every result, alongside the existing text content; failures that still carry data use isError: true; read-only annotations are set.",
86
+ "files": [],
87
+ "assertions": [
88
+ "Adds an outputSchema built with typebox Type.Object/Type.Array",
89
+ "Returns structuredContent matching the schema on every result",
90
+ "Keeps model-facing text in content",
91
+ "Explains that codemode scripts receive structuredContent instead of the text only when outputSchema is declared",
92
+ "Uses isError: true (not a thrown error) for a failure that should still return data, or throws for plain failures",
93
+ "Keeps structuredContent JSON-compatible (no Date objects, Maps, or class instances)",
94
+ "Marks the tool with annotations such as readOnlyHint: true if it does not modify anything",
95
+ "Does not claim the model itself receives structuredContent"
96
+ ]
97
+ },
98
+ {
99
+ "id": 7,
100
+ "prompt": "Write a Pi extension that uses the Jev classifier to decide whether a user prompt is a question or a coding task, and sets a status line accordingly. It should not break when TypeSafe is down or the key is missing.",
101
+ "expected_output": "An extension that finds the classifier with ctx.modelRegistry.findOfType('classifier', 'typesafe', 'jev-latest'), calls ctx.modelRegistry.classify with a choice or bool question and ctx.signal, checks stopReason and the answer type before using probabilities, and falls back cleanly when the model is missing or the result is an error.",
102
+ "files": [],
103
+ "assertions": [
104
+ "Uses ctx.modelRegistry.findOfType('classifier', 'typesafe', 'jev-latest') or getModelOfType with type 'classifier'",
105
+ "Handles the classifier model being undefined",
106
+ "Calls ctx.modelRegistry.classify(model, { state, questions }, { signal })",
107
+ "Uses a valid question shape: type 'choice' with a criteria record, or type 'bool' with criteria { true, false }",
108
+ "Checks result.stopReason === 'stop' before reading answers, and handles 'error' and 'aborted'",
109
+ "Does not wrap classify() in try/catch as the only error handling, since it returns error results instead of rejecting",
110
+ "Checks the answer type (choice/bool) before reading probabilities or probability",
111
+ "Mentions TYPESAFE_API_KEY (or another Jev provider credential) as the auth requirement"
112
+ ]
113
+ },
114
+ {
115
+ "id": 8,
116
+ "prompt": "I'm embedding Pi with the SDK. My host sends user input with session.prompt() while idle and session.steer() while streaming, then waits for agent_settled before showing 'done'. Sometimes it hangs forever on inputs that one of my extensions handles as a command. I also drive a separate Pi over RPC. How do I fix both?",
117
+ "expected_output": "Explains dispositions: session.steer()/followUp() return 'queued' or 'handled'; session.prompt() returns nothing and resolves after an accepted run finishes, so the SDK host should await it instead of waiting for agent_settled; RPC prompt responses carry data.disposition 'started' | 'queued' | 'handled' (steer/follow_up 'queued' | 'handled'); on 'handled' no run started, so the client must not wait for agent_settled. Shows code that branches on the disposition.",
118
+ "files": [],
119
+ "assertions": [
120
+ "States that session.steer() and session.followUp() return 'queued' or 'handled'",
121
+ "States that an RPC prompt response has data.disposition of 'started', 'queued', or 'handled'",
122
+ "Explains that 'handled' means no run started for that input, so the client must not wait for agent_settled",
123
+ "Uses agent_settled (not agent_end) as the completion signal when a run did start",
124
+ "Shows RpcClient.prompt()/steer()/followUp() returning the disposition, or reads data.disposition from the raw response",
125
+ "Notes that session.prompt() resolves after an accepted run finishes",
126
+ "States that session.steer() and session.followUp() throw for extension commands, which must go through session.prompt()",
127
+ "Does not suggest a timeout as the fix"
128
+ ]
48
129
  }
49
130
  ]
50
131
  }
@@ -0,0 +1,46 @@
1
+ // A permission gate that asks a classifier model whether a bash command is destructive.
2
+ import type { ClassifierContext } from "@earendil-works/pi-ai";
3
+ import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
4
+
5
+ const QUESTIONS = {
6
+ destructive: {
7
+ type: "bool",
8
+ instructions: "Could running `command` delete or overwrite data the user may want to keep?",
9
+ criteria: { true: "Destructive", false: "Not destructive" },
10
+ },
11
+ } satisfies ClassifierContext["questions"];
12
+
13
+ // Highest P(destructive) that runs without asking. Tune it for your model and question.
14
+ const MAX_ALLOWED_PROBABILITY = 0.2;
15
+
16
+ export default function (pi: ExtensionAPI) {
17
+ pi.on("tool_call", async (event, ctx) => {
18
+ // Also fires for calls codemode scripts make; those carry event.parentToolCallId.
19
+ if (!isToolCallEventType("bash", event)) return;
20
+
21
+ const jev = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
22
+ let probability: number | undefined;
23
+ if (jev) {
24
+ // classify() never rejects: errors and aborts come back as stopReason.
25
+ const result = await ctx.modelRegistry.classify(
26
+ jev,
27
+ { state: { command: event.input.command }, questions: QUESTIONS },
28
+ { signal: ctx.signal },
29
+ );
30
+ if (result.stopReason === "aborted") return { block: true, reason: "Cancelled" };
31
+ const answer = result.stopReason === "stop" ? result.answers.destructive : undefined;
32
+ if (answer?.type === "bool" && Number.isFinite(answer.probability)) {
33
+ probability = answer.probability;
34
+ }
35
+ }
36
+
37
+ // Allow only a valid answer at or below the threshold; anything else needs a person.
38
+ if (probability !== undefined && probability >= 0 && probability <= MAX_ALLOWED_PROBABILITY) return;
39
+
40
+ const reason =
41
+ probability === undefined ? "Classifier unavailable" : "Classifier flagged the command";
42
+ if (!ctx.hasUI) return { block: true, reason: `${reason}; no UI to confirm` };
43
+ const ok = await ctx.ui.confirm(`${reason}. Run it?`, event.input.command);
44
+ if (!ok) return { block: true, reason: "Blocked by user" };
45
+ });
46
+ }
@@ -0,0 +1,52 @@
1
+ // A tool that runs other tools with ctx.executeTool() and keeps abort distinct from failure.
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ pi.registerTool({
7
+ name: "read_many",
8
+ label: "Read many",
9
+ description: "Read several files in parallel through the read tool.",
10
+ parameters: Type.Object({
11
+ paths: Type.Array(Type.String(), { minItems: 1, maxItems: 20 }),
12
+ }),
13
+ // Declared to the model but never callable from other tools, so codemode cannot recurse into it.
14
+ exposure: "model-only",
15
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
16
+ // ctx.tools lists what ctx.executeTool() can reach: active direct tools plus every
17
+ // codemode/deferred tool. An inactive direct tool is not callable.
18
+ if (!ctx.tools.some((tool) => tool.name === "read")) {
19
+ throw new Error("read_many needs the read tool to be active");
20
+ }
21
+
22
+ let finished = 0;
23
+ const outcomes = await Promise.all(
24
+ params.paths.map(async (path) => {
25
+ // Each call runs tool_call/tool_result handlers (permission gates included) with
26
+ // parentToolCallId set to this call's id. It never rejects; failures come back as isError.
27
+ const outcome = await ctx.executeTool("read", { path }, { signal });
28
+ finished += 1;
29
+ onUpdate?.({
30
+ content: [{ type: "text", text: `Read ${finished}/${params.paths.length}` }],
31
+ details: undefined,
32
+ });
33
+ return { path, outcome };
34
+ }),
35
+ );
36
+
37
+ // Aborted nested calls also come back as isError. Rethrow the abort instead of reporting
38
+ // every file as failed.
39
+ signal?.throwIfAborted();
40
+
41
+ const failed = outcomes.filter(({ outcome }) => outcome.isError).map(({ path }) => path);
42
+ return {
43
+ content: outcomes.flatMap(({ path, outcome }) => [
44
+ { type: "text" as const, text: `--- ${path}${outcome.isError ? " (failed)" : ""}` },
45
+ ...outcome.result.content,
46
+ ]),
47
+ details: { failed },
48
+ isError: failed.length === params.paths.length,
49
+ };
50
+ },
51
+ });
52
+ }
@@ -0,0 +1,61 @@
1
+ // A tool that returns data: codemode scripts receive `structuredContent`, the model reads `content`.
2
+ import { stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { type Static, Type } from "typebox";
7
+
8
+ const FileStatsResult = Type.Object({
9
+ files: Type.Array(
10
+ Type.Object({
11
+ path: Type.String(),
12
+ bytes: Type.Number(),
13
+ modified: Type.String({ description: "ISO 8601 timestamp" }),
14
+ }),
15
+ ),
16
+ missing: Type.Array(Type.String()),
17
+ });
18
+
19
+ type FileStatsResult = Static<typeof FileStatsResult>;
20
+
21
+ export default function (pi: ExtensionAPI) {
22
+ pi.registerTool({
23
+ name: "file_stats",
24
+ label: "File stats",
25
+ description: "Return the size and modification time of files in the working directory.",
26
+ parameters: Type.Object({
27
+ paths: Type.Array(Type.String(), { minItems: 1, maxItems: 100 }),
28
+ }),
29
+ outputSchema: FileStatsResult,
30
+ annotations: { readOnlyHint: true, openWorldHint: false },
31
+ namespace: { name: "fs_info", description: "Read-only file metadata" },
32
+ // Default exposure is "direct". "codemode" would keep it out of the model's tool list
33
+ // while scripts can still call it.
34
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
35
+ const result: FileStatsResult = { files: [], missing: [] };
36
+ for (const path of params.paths) {
37
+ signal?.throwIfAborted();
38
+ try {
39
+ const stats = await stat(resolve(ctx.cwd, path));
40
+ result.files.push({ path, bytes: stats.size, modified: stats.mtime.toISOString() });
41
+ } catch (error: unknown) {
42
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
43
+ result.missing.push(path);
44
+ } else {
45
+ throw error;
46
+ }
47
+ }
48
+ }
49
+
50
+ const lines = result.files.map((file) => `${file.path}: ${file.bytes} bytes`);
51
+ if (result.missing.length > 0) lines.push(`Missing: ${result.missing.join(", ")}`);
52
+ return {
53
+ content: [{ type: "text", text: lines.join("\n") }],
54
+ details: undefined,
55
+ structuredContent: result,
56
+ // A failure that still carries data: the model sees an error, scripts still get the object.
57
+ isError: result.files.length === 0,
58
+ };
59
+ },
60
+ });
61
+ }
@@ -0,0 +1,92 @@
1
+ # Codemode and Tool Search
2
+
3
+ `codemode` is a built-in tool (Pi 0.99) that runs model-written JavaScript in a QuickJS sandbox. Scripts call Pi's other tools, filter their results, and return only what the model needs. `tool_search` declares tools the model cannot see yet. Checked against Pi v1.0.0: [codemode.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/codemode.md), [cli.md#tools](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/cli.md#tools), [extensions.md#tool-exposure](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md#tool-exposure), [settings.md#tools](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/settings.md#tools), [codemode tool source](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/extensions/codemode/tool.ts).
4
+
5
+ ## Enable
6
+
7
+ Both tools are registered inactive. The default tools stay `read`, `bash`, `edit`, `write`.
8
+
9
+ ```json
10
+ { "defaultTools": ["+codemode"] }
11
+ ```
12
+
13
+ - `+name` adds to the default selection; `--tools` replaces it, so list everything: `pi --tools read,bash,edit,write,codemode`. `--tools` is an allowlist over every tool, including extension and MCP `mcp__*` tools: unlisted tools are not registered, so scripts can call only the listed ones.
14
+ - The MCP extension activates `codemode` for servers with `codemode` exposure and `tool_search` for `deferred` servers (see `references/mcp.md`).
15
+ - SDK sessions need `createCodemodeExtension()` / `createToolSearchExtension()` in the resource loader (see `references/sdk.md`).
16
+
17
+ | Setting | Default | Effect |
18
+ |---------|---------|--------|
19
+ | `codemode.mode` | `"on"` | `on`: declared tools stay declared and their descriptions show the script call; `codemode` lists only undeclared tools. `only`: active built-in and extension tools are hidden from the model and listed in the `codemode` description instead |
20
+ | `codemode.inlineBudget` | `3000` | Estimated tokens the `codemode` description may spend on tool declarations; the rest are found with `searchTools()`. `0` lists only namespaces |
21
+
22
+ ## Script API
23
+
24
+ The script body is an async function: top-level `await` and `return` work.
25
+
26
+ - `await tools.<name>(args)` calls a tool. Characters that are not valid in an identifier become `_` (`mcp__dev-radius__search` → `tools.mcp__dev_radius__search`).
27
+ - Since Pi 1.0.0, reading a member of `tools`, `models`, or another global namespace that does not exist throws an error naming the close matches (`tools.Bash` suggests `tools.bash`). Check for a tool with `"name" in tools`; `typeof tools.name` now throws.
28
+ - `ALL_TOOLS` lists `{ name, description }`. `await searchTools(query, { limit, namespace })` ranks tools with BM25; `await describeTool(name)` returns one declaration; `await describeNamespace(name)` returns `{ name, description?, instructions?, tools }` for a namespace such as an MCP server (`mcp__dev-radius`, `dev_radius`, and similar spellings all work).
29
+ - Output: `text(value)`, `image(dataUrlOrImageContent)`, `console.*`, and the returned value. Since Pi 0.99.2 `image()` accepts only base64 PNG, JPEG, GIF, or WebP (a `data:` URL, `{ image_url }`, or an image block); it derives the MIME type from the image bytes, rejects remote URLs, and throws a `TypeError` for anything else, such as an SVG. `exit()` ends early and keeps output.
30
+ - `store(key, value)` / `load(key)` keep JSON values across `codemode` calls (storing `undefined` deletes). Successful scripts append a `codemode-store` custom entry, so values follow the session branch. Limits: 262144 characters of JSON per value, 1048576 in total; do not store image data.
31
+ - `models.getModelsOfType`, `getAvailableOfType`, `getModelOfType`, `models.classify(model, { state, questions })`, and `models.generateImages(model, { input })` (Pi 1.0.0) reach the model catalog, classifiers, and image models with session credentials. Both calls use only the model's `provider` and `id`, never throw on provider errors (check `stopReason`), and run at most four at a time per script. Their usage and cost are added to the `codemode` result. Show generated images with `image(block)` rather than printing `data`; they are not saved to disk, and generation can take minutes, so avoid a short `timeout_ms`.
32
+ - Malformed `classify()`/`generateImages()` arguments are rejected with the expected shape, and an unknown model points to `models.getAvailableOfType()`.
33
+ - An optional first line `// @options: {"max_output_tokens": 2000, "timeout_ms": 60000}`. Output is capped at 10000 estimated tokens by default (start and end kept, full text in a temp file); there is no timeout unless set.
34
+ - No timers, `fetch`, `process`, `require`, or modules. The VM has 256 MB of memory; a script awaiting a promise that can never settle fails at once; scripts cannot start other `codemode` scripts.
35
+
36
+ The result starts with `Script completed` or `Script failed`, the wall time, and the output. A failed script keeps its partial output followed by `Script error:`.
37
+
38
+ ## What a Script Receives
39
+
40
+ | Tool | `await tools.x(...)` resolves to |
41
+ |------|--------------------------------|
42
+ | Declares `outputSchema` | Its `structuredContent`, also when the result has `isError: true` |
43
+ | MCP tool | The whole `CallToolResult` (`content`, `structuredContent`, `isError`) |
44
+ | `bash` | `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`, also for non-zero exits; `output` holds up to 1 MiB |
45
+ | Anything else | Its text content as one string |
46
+
47
+ A failed, blocked, or invalid call without structured content rejects with an `Error` carrying the tool's error text. Catch it in the script (`Promise.allSettled` works) when partial results are useful.
48
+
49
+ ## Which Tools Are Callable
50
+
51
+ "Active" means declared to the model (`pi.getActiveTools()`). "Callable" means reachable through `ctx.executeTool()`, which is how codemode calls tools. They are separate:
52
+
53
+ | Tool `exposure` | Declared to the model | Callable by codemode/`ctx.executeTool()` |
54
+ |-----------------|----------------------|-------------------------------------------|
55
+ | `direct` (default) | While active | Only while active |
56
+ | `model-only` | While active | Never; for tools that orchestrate other tools or ask the user |
57
+ | `codemode` | Only if explicitly activated | Always, and listed in the `codemode` description |
58
+ | `deferred` | After `tool_search` loads it | Always, but not listed; found by `tool_search`/`searchTools()` |
59
+ | `hidden` | Never | Never |
60
+
61
+ MCP tools with the default server `codemode` exposure are registered like `deferred` tools: callable, but not listed in the `codemode` description, so it stays the same while servers connect. Scripts find them with `searchTools()` or `describeNamespace()`.
62
+
63
+ So an inactive `codemode`/`deferred` tool is still callable, while an inactive `direct` tool is unreachable from scripts. Because `codemode`/`deferred` tools do not depend on the active set, they stay callable after `/tree`, resume, and fork. `ctx.tools` in a tool's `execute()` lists exactly the callable tools.
64
+
65
+ ## Nested Calls and Permissions
66
+
67
+ Each `tools.*` call runs through `ctx.executeTool()`, the same pipeline as a model-issued call. `models.classify()` and `models.generateImages()` do not: they run with the session's credentials and fire no `tool_call`/`tool_result` events, so a permission extension cannot gate them separately from the `codemode` call itself.
68
+
69
+ - Argument validation, then `tool_call` and `tool_result` handlers, then `tool_execution_start`/`update`/`end` events.
70
+ - Every event carries `parentToolCallId` (the `codemode` call's id); the nested call's `toolCallId` is `<parent id>/<n>`. Deeper calls nest the same way.
71
+ - Nested calls are not transcript tool calls or results. Only the calling tool's result reaches the model. The session keeps a bounded `nestedCalls` record on that result (name, arguments, status, duration, error; never results; arguments over 8 KiB per call or 32 KiB per result omitted; at most 256 calls; `complete: false` when anything was dropped).
72
+ - A `tool_call` block makes the script's `await tools.x()` reject. The script can continue.
73
+
74
+ For a permission extension this means:
75
+
76
+ - Judge a nested call from the live `tool_call` event (`event.toolName`, `event.input`, `event.parentToolCallId`). Do not look the id up in the last assistant message; it is not there.
77
+ - Do not approve a child because its parent `codemode` call was approved, and do not parse the script to predict children.
78
+ - `nestedCalls` is written after execution and may omit arguments; do not use it as the approval record.
79
+ - In print/JSON mode (`ctx.hasUI === false`) there is no one to ask: block or apply a non-interactive policy.
80
+ - `pi.getAllTools()` reports `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). They are unverified hints, useful for deciding which calls to confirm, not authority to skip policy.
81
+
82
+ ## Tool Search
83
+
84
+ `tool_search` searches tools that are not declared (`codemode` and `deferred` exposure, such as MCP tools) with the same ranking as `searchTools()`, and declares the matches from the next model call on. Loaded tools are recorded like other tool changes and stay declared on that branch. Enable it with `"defaultTools": ["+tool_search"]` when no MCP server does.
85
+
86
+ ## Replacing or Disabling
87
+
88
+ - An extension that registers a tool named `codemode` or `tool_search` replaces the built-in one.
89
+ - `"extensions": ["-builtin:codemode"]` or `["-builtin:tool-search"]` disables one (note the hyphen in the extension name); `pi config` lists them under Built-in.
90
+ - `codemode` uses only `exposure`, `prepareLoadout()`, and `ctx.executeTool()`, and `tool_search` only ranks registered tools (`pi.getAllTools()`) and adds matches with `pi.setActiveTools()`, so an extension can build either under another name.
91
+
92
+ `@earendil-works/pi-codemode` publishes the sandbox (`CodemodeSandbox`) without Pi dependencies, for exposing any functions to model-written scripts.