pi-code 1.0.1 → 1.0.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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
38
38
  | Feature | Reads / provides | Extension |
39
39
  |---|---|---|
40
40
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
41
- | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`model`/`argument-hint` frontmatter; project commands gated on approval | `commands.ts` |
41
+ | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`argument-hint` frontmatter (`model` is parsed but not yet applied); project commands gated on approval | `commands.ts` |
42
42
  | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
43
43
  | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
44
44
  | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
@@ -46,7 +46,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
46
46
  | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed` | `mcp.ts` |
47
47
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles, commands, skills) that pi would otherwise trust silently | `internal/project-approval.ts` |
48
48
  | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; `skills` preload; background runs with cancel and resume | `subagent/` |
49
- | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
49
+ | Plan mode | `plan_mode_complete` tool, tool snapshot/restore that survives `/reload` | `plan-mode/` |
50
50
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
51
51
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later; 100 per session, repos pruned after 30 days | `git-checkpoint.ts` |
52
52
  | Persistent memory | per-project memories, index injected each session within Claude's 200-line/25KB bound; a save that would overflow it reports why | `memory.ts` |
@@ -57,7 +57,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
57
57
 
58
58
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
59
59
 
60
- `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), and `project-approval.ts` (the trust decision above). The extensions use them; only `internal/` keeps them out of pi's extension scan.
60
+ `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), `project-approval.ts` (the trust decision above), `command-file.ts` (slash-command parsing and dynamic content), and the shared-bus contracts `mcp-alias.ts`, `plan-mode-state.ts` and `subagent-events.ts`. The extensions use them; only `internal/` keeps them out of pi's extension scan.
61
61
 
62
62
  Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
63
63
 
@@ -6,8 +6,10 @@
6
6
  * is what makes the rest of Claude's command contract reachable: namespaced
7
7
  * subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
8
8
  * positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
9
- * `allowed-tools` / `model` / `argument-hint` / `disable-model-invocation`
10
- * frontmatter.
9
+ * `allowed-tools` and `argument-hint` frontmatter. `model` and
10
+ * `disable-model-invocation` are parsed but not applied yet: pi has seams for both
11
+ * (`pi.setModel`, and commands are user-invoked anyway), so they are a gap rather
12
+ * than an impossibility.
11
13
  *
12
14
  * A project command body is repository-controlled text that can now run shell
13
15
  * commands and read files, so project commands load only once the project is
@@ -59,6 +61,14 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
59
61
 
60
62
  export default function commandsExtension(pi: ExtensionAPI) {
61
63
  const registered = new Set<string>()
64
+ /** Tool set to put back once the turn a restricted command drove has ended. */
65
+ let pendingRestore: string[] | undefined
66
+
67
+ pi.on('turn_end', async () => {
68
+ if (!pendingRestore) return
69
+ pi.setActiveTools(pendingRestore)
70
+ pendingRestore = undefined
71
+ })
62
72
 
63
73
  async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
64
74
  const withArgs = substituteArgs(parsed.body, args)
@@ -71,17 +81,23 @@ export default function commandsExtension(pi: ExtensionAPI) {
71
81
  return { stdout: result.stdout, stderr: result.stderr, code: result.code }
72
82
  })
73
83
 
74
- // allowed-tools restricts the turn the command drives, then the previous set is
75
- // restored: the restriction belongs to the command, not to the rest of the session.
76
- const saved = parsed.allowedTools ? pi.getActiveTools() : undefined
77
- if (parsed.allowedTools && saved) {
78
- pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
79
- }
80
- try {
81
- pi.sendUserMessage(expanded)
82
- } finally {
83
- if (saved) pi.setActiveTools(saved)
84
+ // allowed-tools restricts the turn the command drives, and the previous set is
85
+ // restored when that turn ends. Restoring inline does not work: sendUserMessage is
86
+ // fire-and-forget, so the restore would land before the agent ever read the tool
87
+ // list, leaving the command running with everything enabled.
88
+ if (parsed.allowedTools) {
89
+ const saved = pi.getActiveTools()
90
+ const granted = parsed.allowedTools.filter((tool) => saved.includes(tool))
91
+ // Only the first restriction in a turn knows the unrestricted set; a second
92
+ // command would otherwise record the first one's narrowed set as the thing to
93
+ // restore, and the tools the first command dropped would never come back.
94
+ pendingRestore ??= saved
95
+ // `allowed-tools: []` says no tools, and is honored. A non-empty list that
96
+ // intersects to nothing named only tools pi has none of: that restriction cannot
97
+ // be expressed, and applying it as "no tools" is not what the command asked for.
98
+ if (granted.length > 0 || parsed.allowedTools.length === 0) pi.setActiveTools(granted)
84
99
  }
100
+ pi.sendUserMessage(expanded)
85
101
  }
86
102
 
87
103
  pi.on('session_start', async (_event, ctx) => {
@@ -97,8 +97,14 @@ export function loadHooks(files: string[]): HooksConfig {
97
97
  } catch {
98
98
  continue
99
99
  }
100
- for (const [event, matchers] of Object.entries(parsed.hooks ?? {})) {
101
- if (Array.isArray(matchers)) config[event] = [...(config[event] ?? []), ...matchers]
100
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
101
+ if (!Array.isArray(matchers)) continue
102
+ // Entries are validated here rather than where they run: a hand-edited settings
103
+ // file that writes `hooks` as an object instead of a list used to throw out of
104
+ // the tool_call handler, and pi turns that into an error result, so every tool
105
+ // call for the rest of the session failed with an opaque type error.
106
+ const usable = matchers.filter((entry) => isUsableMatcher(entry, file, event))
107
+ if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
102
108
  }
103
109
  }
104
110
  return config
@@ -124,6 +130,26 @@ function exactListApplies(matcher: string, names: readonly string[]): boolean {
124
130
  return names.some((name) => tokens.has(foldName(name)))
125
131
  }
126
132
 
133
+ /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
134
+ * is reported by name and skipped, so one bad entry costs its own hooks, not the
135
+ * session's tool calls. */
136
+ function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
137
+ const candidate = entry as HookMatcher | null
138
+ if (candidate === null || typeof candidate !== 'object') {
139
+ console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
140
+ return false
141
+ }
142
+ if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
143
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
144
+ return false
145
+ }
146
+ if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
147
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
148
+ return false
149
+ }
150
+ return true
151
+ }
152
+
127
153
  function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
128
154
  if (!matcher || matcher === '*') return true
129
155
  if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
@@ -12,6 +12,8 @@
12
12
  import * as fs from 'node:fs'
13
13
  import * as path from 'node:path'
14
14
 
15
+ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
+
15
17
  export interface ParsedCommand {
16
18
  description: string
17
19
  argumentHint?: string
@@ -27,28 +29,109 @@ export interface DiscoveredCommand {
27
29
  filePath: string
28
30
  }
29
31
 
30
- /** Claude tool names are PascalCase; pi's are lowercase. */
31
- function normalizeToolName(name: string): string {
32
- return name.trim().toLowerCase()
32
+ /** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
33
+ * `find`. Lowercasing alone left `glob` in the list, and since pi has no tool by
34
+ * that name the grant was silently dropped when the list was intersected with the
35
+ * active tools. Shared with the subagent's own frontmatter parsing. */
36
+ const CLAUDE_TOOL_MAP: Record<string, string> = {
37
+ read: 'read',
38
+ write: 'write',
39
+ edit: 'edit',
40
+ bash: 'bash',
41
+ grep: 'grep',
42
+ glob: 'find',
43
+ ls: 'ls',
44
+ // Claude's names for the tools this package registers itself. Without these a
45
+ // perfectly ordinary `allowed-tools: WebFetch, WebSearch` matched no pi tool and
46
+ // the intersection left the turn with nothing.
47
+ webfetch: 'web_fetch',
48
+ websearch: 'web_search',
49
+ todowrite: 'todo',
50
+ todoread: 'todo',
51
+ task: 'subagent',
52
+ askuserquestion: 'question',
53
+ exitplanmode: 'plan_mode_complete',
33
54
  }
34
55
 
35
- function field(frontmatter: string, key: string): string {
36
- const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
37
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
56
+ /**
57
+ * Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
58
+ * pi's active-tool list is per tool, with no argument dimension, so the scope is
59
+ * dropped and the base tool is granted. Keeping the scope in the name matched nothing
60
+ * when the list was intersected with the active tools, which left a command declaring
61
+ * only scoped grants running with no tools at all.
62
+ */
63
+ export function normalizeToolName(name: string): string {
64
+ const lower = name.trim().toLowerCase()
65
+ const scope = lower.indexOf('(')
66
+ const base = (scope === -1 ? lower : lower.slice(0, scope)).trim()
67
+ return CLAUDE_TOOL_MAP[base] ?? base
38
68
  }
39
69
 
70
+ /**
71
+ * Entries are comma-separated, except a comma inside an argument scope belongs to the
72
+ * scope: `Bash(cat, tail)` is one grant, not three. Splitting on every comma made the
73
+ * fragments between them top-level entries, so a command naming only `Bash` came away
74
+ * with pi's `edit` tool active.
75
+ *
76
+ * Scanned rather than matched with a regex: the pattern form is quadratic on an input
77
+ * of unclosed parens, and a command file comes from the repository.
78
+ */
79
+ export function toolEntries(raw: string): string[] {
80
+ const entries: string[] = []
81
+ let current = ''
82
+ let depth = 0
83
+ for (const ch of raw) {
84
+ if (ch === '(') depth++
85
+ else if (ch === ')') depth = Math.max(0, depth - 1)
86
+ if (ch === ',' && depth === 0) {
87
+ entries.push(current)
88
+ current = ''
89
+ } else {
90
+ current += ch
91
+ }
92
+ }
93
+ entries.push(current)
94
+ return entries.map((entry) => entry.trim()).filter(Boolean)
95
+ }
96
+
97
+ /**
98
+ * A tool grant is either a comma-separated string or a YAML list, and the two mean the
99
+ * same thing. An empty list is not the same as an absent one: it says no tools, so it
100
+ * comes back as an empty array rather than undefined.
101
+ */
102
+ export function parseToolList(raw: unknown): string[] | undefined {
103
+ if (raw === undefined || raw === null) return undefined
104
+ let items: unknown[]
105
+ if (Array.isArray(raw)) items = raw
106
+ else if (typeof raw === 'string') items = toolEntries(raw)
107
+ else return undefined
108
+ if (items.some((item) => typeof item !== 'string')) return undefined
109
+ return [...new Set((items as string[]).map(normalizeToolName).filter(Boolean))]
110
+ }
111
+
112
+ /** YAML types a bare scalar, so a model named `3.5` arrives as a number, not a string. */
113
+ const text = (value: unknown): string => {
114
+ if (typeof value === 'string') return value.trim()
115
+ return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
116
+ }
117
+
118
+ /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
119
+ const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
120
+
40
121
  export function parseCommandFile(content: string): ParsedCommand {
41
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
42
- const frontmatter = match ? match[1] : ''
43
- const body = (match ? content.slice(match[0].length) : content).trim()
44
- const tools = field(frontmatter, 'allowed-tools')
122
+ // pi's own parser, rather than a hand-rolled one: it reads the YAML shapes Claude
123
+ // command files actually use (flow sequences, block lists, quoted and multi-line
124
+ // values), and a value this misreads is a restriction silently not applied.
125
+ const { frontmatter, body: raw } = parseFrontmatter(content)
126
+ const body = raw.trim()
45
127
  const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
128
+ const disable = frontmatter['disable-model-invocation']
46
129
  return {
47
- description: field(frontmatter, 'description') || firstLine.slice(0, 60),
48
- argumentHint: field(frontmatter, 'argument-hint') || undefined,
49
- allowedTools: tools ? tools.split(',').map(normalizeToolName).filter(Boolean) : undefined,
50
- model: field(frontmatter, 'model') || undefined,
51
- disableModelInvocation: field(frontmatter, 'disable-model-invocation') === 'true',
130
+ description: text(frontmatter.description) || firstLine.slice(0, 60),
131
+ argumentHint: hint(frontmatter['argument-hint']) || undefined,
132
+ allowedTools: parseToolList(frontmatter['allowed-tools']),
133
+ model: text(frontmatter.model) || undefined,
134
+ disableModelInvocation: disable === true || text(disable) === 'true',
52
135
  body,
53
136
  }
54
137
  }
@@ -128,12 +211,16 @@ const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ra
128
211
  /** Read a `@path` reference, confined to the working directory. Returns undefined
129
212
  * when the path escapes it or cannot be read, so the reference stays literal. */
130
213
  function readReference(cwd: string, reference: string): string | undefined {
131
- const resolved = path.resolve(cwd, reference)
132
- const root = path.resolve(cwd)
133
- if (resolved !== root && !resolved.startsWith(root + path.sep)) return undefined
134
214
  try {
135
- if (!fs.statSync(resolved).isFile()) return undefined
136
- return fs.readFileSync(resolved, 'utf-8')
215
+ // Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
216
+ // resolved path against an unresolved root rejects every legitimate read.
217
+ const root = fs.realpathSync(cwd)
218
+ // Confinement is checked after symlinks resolve: a lexical check passes a link
219
+ // that points outside the project, and the read would follow it.
220
+ const real = fs.realpathSync(path.resolve(cwd, reference))
221
+ if (real !== root && !real.startsWith(root + path.sep)) return undefined
222
+ if (!fs.statSync(real).isFile()) return undefined
223
+ return fs.readFileSync(real, 'utf-8')
137
224
  } catch {
138
225
  return undefined
139
226
  }
@@ -12,11 +12,22 @@
12
12
 
13
13
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '@earendil-works/pi-coding-agent'
14
14
 
15
+ /**
16
+ * Trim `text` to a byte budget. `String.slice` counts UTF-16 units, so slicing a CJK
17
+ * string by a byte budget keeps up to three times the bytes asked for; cutting the
18
+ * encoded buffer is exact. A character straddling the cut decodes to U+FFFD.
19
+ * Shorter input comes back whole and a negative budget yields nothing, so callers
20
+ * need no length check of their own.
21
+ */
22
+ export function sliceBytes(text: string, maxBytes: number): string {
23
+ return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)).toString('utf-8')
24
+ }
25
+
15
26
  /** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
16
27
  export function capForContext(text: string): string {
17
28
  const cut = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
18
29
  if (!cut.truncated) return text
19
- const kept = cut.content || text.slice(0, DEFAULT_MAX_BYTES)
30
+ const kept = cut.content || sliceBytes(text, DEFAULT_MAX_BYTES)
20
31
  const capped = `${kept}\n\n[truncated: ${formatSize(cut.totalBytes)} total, ${cut.totalLines} lines]`
21
32
  // Just over the budget, the notice can cost more than the trim saves.
22
33
  return capped.length < text.length ? capped : text
@@ -29,14 +29,33 @@ const CLAUDE_SHAPED = [
29
29
  path.join('.claude', 'hooks'),
30
30
  path.join('.claude', 'output-styles'),
31
31
  path.join('.claude', 'rules'),
32
+ path.join('.claude', 'skills'),
33
+ path.join('.claude', 'commands'),
32
34
  'CLAUDE.local.md',
33
35
  '.mcp.json',
34
36
  path.join('.pi', 'mcp.json'),
35
37
  path.join('.pi', 'agents'),
36
38
  ]
37
39
 
40
+ /** Markers that end the upward walk, matching the subagent's own discovery bound. */
41
+ const ROOT_MARKERS = ['.git', 'package.json']
42
+
43
+ /** Claude-shaped config anywhere between `cwd` and the repository root.
44
+ *
45
+ * The walk matters: agent discovery already searches upward, so starting pi in a
46
+ * subdirectory of a repository whose `.claude/agents` sits at the root found those
47
+ * agents while a cwd-only check reported nothing to gate, and the short-circuit
48
+ * approved the project without ever asking. The bound is the repository root, so a
49
+ * directory outside any repository never inherits a parent's config. */
38
50
  export function hasClaudeShapedConfig(cwd: string): boolean {
39
- return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
51
+ let currentDir = cwd
52
+ while (true) {
53
+ if (CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(currentDir, entry)))) return true
54
+ if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return false
55
+ const parentDir = path.dirname(currentDir)
56
+ if (parentDir === currentDir) return false
57
+ currentDir = parentDir
58
+ }
40
59
  }
41
60
 
42
61
  export interface ApprovalContext {
package/extensions/mcp.ts CHANGED
@@ -22,6 +22,7 @@ import * as fs from 'node:fs'
22
22
  import * as os from 'node:os'
23
23
  import * as path from 'node:path'
24
24
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
25
+ import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
25
26
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
26
27
  // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
27
28
  // the old spec exist, so this stays as a fallback for the migration period.
@@ -32,7 +33,7 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/typ
32
33
  import { Type } from 'typebox'
33
34
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
34
35
  import { capForContext } from './internal/output-guard.js'
35
- import { isProjectApproved } from './internal/project-approval.js'
36
+ import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
36
37
 
37
38
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
38
39
  const DEFAULT_CALL_TIMEOUT_MS = 120_000
@@ -103,13 +104,18 @@ export interface ProjectServerPolicy {
103
104
  consentAll: boolean
104
105
  }
105
106
 
106
- /** Claude's per-server approvals for project .mcp.json servers. Consent-granting keys
107
- * (enabledMcpjsonServers, enableAllProjectMcpServers) count only from files the repo
108
- * does not control (user settings and settings.local.json), so a checked-in
109
- * settings.json cannot approve its own servers. disabledMcpjsonServers counts from
110
- * every file and wins over consent. Lists union across files: for denies the union is
111
- * the restrictive reading, and consent is the union of the user's own two files. */
112
- export function projectServerPolicy(cwd: string, home: string): ProjectServerPolicy {
107
+ /** Claude's per-server approvals for project .mcp.json servers.
108
+ *
109
+ * Consent-granting keys (enabledMcpjsonServers, enableAllProjectMcpServers) count
110
+ * from the user's own settings always, and from the project's settings.local.json
111
+ * only once the project itself is approved. That file is gitignored by convention,
112
+ * not by enforcement: a repository can commit one, and honoring it unconditionally
113
+ * let a hostile repo self-approve a server whose `command` runs on connect, even
114
+ * after the user declined the trust prompt.
115
+ *
116
+ * disabledMcpjsonServers counts from every file, including the repo's own, and wins
117
+ * over consent: a repo may always restrict itself further, never less. */
118
+ export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
113
119
  const read = (file: string): Record<string, unknown> => {
114
120
  try {
115
121
  return JSON.parse(fs.readFileSync(file, 'utf-8'))
@@ -122,7 +128,7 @@ export function projectServerPolicy(cwd: string, home: string): ProjectServerPol
122
128
  const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
123
129
  const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
124
130
  const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
125
- const consentSources = [userSettings, localSettings]
131
+ const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
126
132
  const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
127
133
  const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
128
134
  return { disabled, consented, consentAll }
@@ -217,12 +223,14 @@ export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data
217
223
  export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
218
224
  // capForContext every text output, whatever its source: a server can blow the tool-output
219
225
  // budget through a resource block, a JSON-stringified block, or the structured fallback,
220
- // not only a text block.
226
+ // not only a text block. The per-block cap alone is not a budget, though: a server
227
+ // answering with one block per file multiplies it by the block count, so the blocks
228
+ // are capped again as a whole below.
221
229
  const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
222
230
  if (!content || content.length === 0) {
223
231
  return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
224
232
  }
225
- return content.map((block) => {
233
+ const mapped: ToolContent[] = content.map((block): ToolContent => {
226
234
  if (block.type === 'text') {
227
235
  return text(block.text ?? '')
228
236
  }
@@ -234,6 +242,50 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
234
242
  }
235
243
  return text(JSON.stringify(block))
236
244
  })
245
+ return capTotal(mapped)
246
+ }
247
+
248
+ /**
249
+ * Bound a result's text as a whole, not each block. The per-block cap multiplies by
250
+ * the block count, so a server answering with one block per file still injects
251
+ * megabytes.
252
+ *
253
+ * Blocks are kept whole. Each has already been capped on its own, so keeping the one
254
+ * that crosses the budget bounds the text at roughly a single cap rather than at the
255
+ * block count times it, and it preserves that block's own truncation notice, which
256
+ * states how much of it was dropped. Blocks after it are omitted rather than skipped
257
+ * over, so what reaches the model is a prefix of what the server sent, and the number
258
+ * omitted is stated so a truncated set is distinguishable from a complete one.
259
+ *
260
+ * Images pass through uncut and do not spend the budget: base64 cut short is a broken
261
+ * image rather than a smaller one, so nothing here can bound them, and charging the
262
+ * budget for one would only delete the caption that accompanies a screenshot.
263
+ */
264
+ export function capTotal(blocks: ToolContent[]): ToolContent[] {
265
+ const kept: ToolContent[] = []
266
+ let spent = 0
267
+ let full = false
268
+ let dropped = 0
269
+ for (const block of blocks) {
270
+ if (block.type !== 'text') {
271
+ kept.push(block)
272
+ continue
273
+ }
274
+ const size = Buffer.byteLength(block.text, 'utf-8')
275
+ // The first text block always goes through: a lone oversized one is better read
276
+ // truncated, with its own notice, than replaced by a marker saying it existed.
277
+ if (full || (spent > 0 && spent + size > DEFAULT_MAX_BYTES)) {
278
+ full = true
279
+ dropped++
280
+ continue
281
+ }
282
+ kept.push(block)
283
+ spent += size
284
+ }
285
+ if (dropped > 0) {
286
+ kept.push({ type: 'text', text: `[${dropped} further content block${dropped === 1 ? '' : 's'} omitted: tool output budget spent]` })
287
+ }
288
+ return kept
237
289
  }
238
290
 
239
291
  function isStdio(config: ServerConfig): config is StdioServerConfig {
@@ -374,7 +426,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
374
426
  if (result.isError) {
375
427
  details.error = 'tool_error'
376
428
  const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
377
- content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
429
+ content.push({ type: 'text', text: capForContext(`Tool reported an error. Expected input schema: ${hint}`) })
378
430
  }
379
431
  return { content, details }
380
432
  },
@@ -406,6 +458,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
406
458
  }
407
459
 
408
460
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
461
+ const pending: [string, ServerConfig][] = []
409
462
  for (const [name, config] of Object.entries(servers)) {
410
463
  // A later scope must not take the name of a server that already connected: it
411
464
  // would evict that client from the map, leaking it at shutdown, and misreport
@@ -414,24 +467,34 @@ export default async function mcpExtension(pi: ExtensionAPI) {
414
467
  console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
415
468
  continue
416
469
  }
417
- warnOnTypelessUrl(name, config)
418
- try {
419
- const client = await connect(name, config)
420
- clients.set(name, client)
421
- const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
422
- const count = registerTools(name, config, client, tools)
423
- subscribeToToolChanges(name, config, client)
424
- status.set(name, { state: 'connected', tools: count })
425
- } catch (error) {
426
- status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
427
- }
470
+ // Seed in config order before connecting: parallel connects settle in completion
471
+ // order, and /mcp plus the session summary iterate the map's insertion order.
472
+ status.set(name, { state: 'connecting', tools: 0 })
473
+ pending.push([name, config])
428
474
  }
475
+ await Promise.all(
476
+ pending.map(async ([name, config]) => {
477
+ warnOnTypelessUrl(name, config)
478
+ try {
479
+ const client = await connect(name, config)
480
+ clients.set(name, client)
481
+ const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
482
+ const count = registerTools(name, config, client, tools)
483
+ subscribeToToolChanges(name, config, client)
484
+ status.set(name, { state: 'connected', tools: count })
485
+ } catch (error) {
486
+ status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
487
+ }
488
+ }),
489
+ )
429
490
  }
430
491
 
431
492
  /** Connect the project scope under the per-server policy. Returns whether the scope
432
493
  * is settled, so a refused confirm can be retried on a later session start. */
433
494
  async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
434
- const policy = projectServerPolicy(ctx.cwd, os.homedir())
495
+ // The stored decision, read without prompting: consent recorded inside the
496
+ // project only counts once the project itself has been approved.
497
+ const policy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
435
498
  const { consented, gated } = splitByPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy)
436
499
  if (Object.keys(consented).length > 0) await connectServers(consented)
437
500
  if (Object.keys(gated).length === 0) return true
@@ -4,9 +4,9 @@ Read-only exploration mode for safe code analysis.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
7
+ - **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question, and `plan_mode_complete`
8
8
  - **Bash allowlist**: Only read-only bash commands are allowed
9
- - **Plan extraction**: Extracts numbered steps from `Plan:` sections
9
+ - **Plan extraction**: Takes the plan from the `plan_mode_complete` tool call, falling back to numbered steps under a `Plan:` header when the model writes prose instead
10
10
  - **Progress tracking**: Widget shows completion status during execution
11
11
  - **[DONE:n] markers**: Explicit step completion tracking
12
12
  - **Session persistence**: State survives session resume
@@ -21,7 +21,7 @@ Read-only exploration mode for safe code analysis.
21
21
 
22
22
  1. Enable plan mode with `/plan` or `--plan` flag
23
23
  2. Ask the agent to analyze code and create a plan
24
- 3. The agent should output a numbered plan under a `Plan:` header:
24
+ 3. The agent calls `plan_mode_complete` with the finished plan. If it writes prose instead, a numbered plan under a `Plan:` header is still picked up:
25
25
 
26
26
  ```
27
27
  Plan:
@@ -132,6 +132,11 @@ export default function planModeExtension(pi: ExtensionAPI): void {
132
132
  enabled: planModeEnabled,
133
133
  todos: todoItems,
134
134
  executing: executionMode,
135
+ // The pre-plan tool set has to survive with the state that caused it to shrink.
136
+ // /reload rebuilds this extension with an empty snapshot while pi carries the
137
+ // restricted tools into the new runtime, so a restore has no way to work out
138
+ // what was active before plan mode unless it was written down here.
139
+ savedTools,
135
140
  })
136
141
  }
137
142
 
@@ -379,7 +384,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
379
384
  const entries = ctx.sessionManager.getEntries()
380
385
 
381
386
  // Restore persisted state
382
- const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
387
+ const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
383
388
 
384
389
  if (planModeEntry?.data) {
385
390
  planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
@@ -396,7 +401,23 @@ After completing a step, include a [DONE:n] tag in your response.`,
396
401
  }
397
402
 
398
403
  if (planModeEnabled) {
399
- enterPlanTools()
404
+ // Restoring into plan mode is the only case the recorded snapshot is for.
405
+ // Re-reading the active set here would capture the restriction pi carried
406
+ // across /reload and cost the session edit and write for good; applying the
407
+ // snapshot when plan mode is off would instead push a stale set over whatever
408
+ // pi has registered since, so it stays scoped to this branch.
409
+ savedTools = planModeEntry?.data?.savedTools ?? pi.getActiveTools()
410
+ pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
411
+ // --plan enters plan mode without ever toggling, so nothing has persisted yet
412
+ // and a /reload would find no snapshot to restore from. Record it now, while
413
+ // the active set still says what was there before the restriction.
414
+ //
415
+ // Only with no entry at all: an entry written before this field existed means
416
+ // the active set has already been through a restore and may be the restriction
417
+ // itself. Those tools are lost for this process either way, but persisting a
418
+ // guess would write the loss into the session file, where a later resume would
419
+ // inherit it instead of starting over.
420
+ if (!planModeEntry) persistState()
400
421
  } else {
401
422
  // A prior session in this instance may have shrunk the tool set; undo that when
402
423
  // the restored/fresh state is not plan mode.
@@ -83,6 +83,7 @@ export default function statusLine(pi: ExtensionAPI) {
83
83
  let sessionCtx: ExtensionContext | undefined
84
84
  let commandLine: string | undefined
85
85
  let permissionMode = 'default'
86
+ let projectApproved = false
86
87
  let refreshTimer: ReturnType<typeof setInterval> | undefined
87
88
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
88
89
  let running = false
@@ -103,7 +104,9 @@ export default function statusLine(pi: ExtensionAPI) {
103
104
  /** The stdin payload per Claude's documented statusline contract. */
104
105
  function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
105
106
  const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
106
- const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), true))
107
+ // Same gate as the config read above: an unapproved project's style is not applied,
108
+ // so reporting it here would describe a style the session is not using.
109
+ const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
107
110
  const payload: Record<string, unknown> = {
108
111
  session_id: ctx.sessionManager.getSessionId(),
109
112
  cwd: ctx.cwd,
@@ -128,11 +131,17 @@ export default function statusLine(pi: ExtensionAPI) {
128
131
  }
129
132
  running = true
130
133
  try {
134
+ // Everything below can touch ctx after an await, and every ctx getter throws
135
+ // once the session is disposed. This promise is started from a timer with no
136
+ // awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
131
137
  const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS)
132
138
  const first = result.stdout.split('\n')[0].trimEnd()
133
139
  const pad = ' '.repeat(config.padding)
134
140
  commandLine = first ? `${pad}${first}${pad}` : undefined
135
141
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
142
+ } catch {
143
+ // A replaced or reloaded session invalidates ctx while the command is in
144
+ // flight; there is nothing left to update, and the next session starts fresh.
136
145
  } finally {
137
146
  running = false
138
147
  if (rerunQueued) {
@@ -168,6 +177,7 @@ export default function statusLine(pi: ExtensionAPI) {
168
177
  // approval at session start, and a second prompt stacks over the first and eats
169
178
  // the keys meant for it. An undecided project simply skips project settings.
170
179
  const trusted = isProjectApprovedSilently(ctx)
180
+ projectApproved = trusted
171
181
  config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
172
182
  if (config?.refreshInterval) {
173
183
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
@@ -41,7 +41,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
41
41
 
42
42
  To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
43
43
 
44
- When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
44
+ When running interactively, the tool prompts for confirmation before running project-local agents. `confirmProjectAgents: false` skips that prompt for a project you have already approved; an unapproved project is still asked about.
45
45
 
46
46
  ## Usage
47
47
 
@@ -5,23 +5,12 @@
5
5
  import * as fs from 'node:fs'
6
6
  import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
- import { getAgentDir, parseFrontmatter } from '@earendil-works/pi-coding-agent'
9
-
10
- // Claude Code tool names -> pi tool names; unmapped names pass through lowercased
11
- const CLAUDE_TOOL_MAP: Record<string, string> = {
12
- read: 'read',
13
- write: 'write',
14
- edit: 'edit',
15
- bash: 'bash',
16
- grep: 'grep',
17
- glob: 'find',
18
- ls: 'ls',
19
- }
8
+ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
20
9
 
21
- function normalizeToolName(tool: string): string {
22
- const lower = tool.toLowerCase()
23
- return CLAUDE_TOOL_MAP[lower] ?? lower
24
- }
10
+ // The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
11
+ // Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
12
+ // is not merely ignored, it narrows the child's registry.
13
+ import { parseToolList } from '../internal/command-file.js'
25
14
 
26
15
  /**
27
16
  * `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
@@ -30,12 +19,12 @@ function normalizeToolName(tool: string): string {
30
19
  */
31
20
  function parseToolsField(raw: unknown): string[] | undefined | null {
32
21
  if (raw === undefined) return undefined
33
- let items: unknown[]
34
- if (Array.isArray(raw)) items = raw
35
- else if (typeof raw === 'string') items = raw.split(',')
36
- else return null
37
- if (items.some((item) => typeof item !== 'string')) return null
38
- const tools = (items as string[]).map((item) => normalizeToolName(item.trim())).filter(Boolean)
22
+ // Shares the command parser's splitting, so a comma inside an argument scope stays
23
+ // inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
24
+ if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
25
+ if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
26
+ const tools = parseToolList(raw)
27
+ if (!tools) return null
39
28
  return tools.length > 0 ? tools : undefined
40
29
  }
41
30
 
@@ -110,21 +99,31 @@ export function withPreloadedSkills(prompt: string, skills: string[] | undefined
110
99
  * prompt sent to the model, so a traversal would be an arbitrary-file read. */
111
100
  const SKILL_NAME = /^[A-Za-z0-9_.-]+$/
112
101
 
102
+ /** Read one candidate file, but only if it really sits under `root` once symlinks
103
+ * are resolved. Both sides are canonicalised: on macOS /var is itself a symlink, so
104
+ * comparing a resolved path against an unresolved root rejects every valid read. */
105
+ function readConfined(candidate: string, root: string): string | undefined {
106
+ try {
107
+ const real = fs.realpathSync(candidate)
108
+ if (real !== root && !real.startsWith(root + path.sep)) return undefined
109
+ return stripFrontmatter(fs.readFileSync(real, 'utf-8'))
110
+ } catch {
111
+ return undefined
112
+ }
113
+ }
114
+
113
115
  function readSkillBody(name: string, skillDirs: string[]): string | undefined {
114
116
  if (!SKILL_NAME.test(name) || name === '.' || name === '..') return undefined
115
117
  for (const dir of skillDirs) {
116
- const root = path.resolve(dir)
118
+ let root: string
119
+ try {
120
+ root = fs.realpathSync(dir)
121
+ } catch {
122
+ continue // a skills directory that does not exist simply contributes nothing
123
+ }
117
124
  for (const candidate of [path.join(dir, name, 'SKILL.md'), path.join(dir, `${name}.md`)]) {
118
- // Belt and braces against symlinks and platform path quirks: the file actually
119
- // read must still sit under the skills directory it was resolved from.
120
- if (!path.resolve(candidate).startsWith(root + path.sep)) continue
121
- try {
122
- const content = fs.readFileSync(candidate, 'utf-8')
123
- const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(content)
124
- return match ? content.slice(match[0].length) : content
125
- } catch {
126
- // try the next shape
127
- }
125
+ const body = readConfined(candidate, root)
126
+ if (body !== undefined) return body
128
127
  }
129
128
  }
130
129
  return undefined
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { spawn } from 'node:child_process'
9
9
  import { randomUUID } from 'node:crypto'
10
+ import * as fs from 'node:fs'
11
+ import * as os from 'node:os'
12
+ import * as path from 'node:path'
10
13
 
11
14
  export interface BackgroundRun {
12
15
  id: string
@@ -28,6 +31,10 @@ export interface BackgroundSpawn {
28
31
  command: string
29
32
  args: string[]
30
33
  cwd: string
34
+ /** The --append-system-prompt body, kept so a resume can rebuild the file the
35
+ * completing run deleted. Without it the resumed child is handed a path that no
36
+ * longer exists, and pi falls back to using that path as the prompt text. */
37
+ promptBody?: string
31
38
  }
32
39
 
33
40
  const runs = new Map<string, BackgroundRun>()
@@ -97,7 +104,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
97
104
  const run = runs.get(id)
98
105
  if (!run) return 'unknown'
99
106
  if (run.state === 'running') return 'still-running'
100
- const args = run.spawn.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
107
+ const args = withRebuiltPrompt(run.spawn).map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
101
108
  run.state = 'running'
102
109
  run.task = task
103
110
  run.output = undefined
@@ -106,6 +113,26 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
106
113
  return 'resumed'
107
114
  }
108
115
 
116
+ /** Re-point --append-system-prompt at a fresh file when the original is gone. */
117
+ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
118
+ const flag = spawnSpec.args.indexOf('--append-system-prompt')
119
+ if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
120
+ const current = spawnSpec.args[flag + 1]
121
+ if (current && fs.existsSync(current)) return spawnSpec.args
122
+ try {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-'))
124
+ const file = path.join(dir, 'prompt.md')
125
+ fs.writeFileSync(file, spawnSpec.promptBody, { mode: 0o600 })
126
+ const rebuilt = [...spawnSpec.args]
127
+ rebuilt[flag + 1] = file
128
+ return rebuilt
129
+ } catch {
130
+ // Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
131
+ // prompt text, which would replace the agent persona with a temp path.
132
+ return spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1)
133
+ }
134
+ }
135
+
109
136
  export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
110
137
  // Checked here, synchronously with registration: callers await temp-file writes
111
138
  // between any check of their own and this call, so a parallel tool-call batch
@@ -148,11 +175,25 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
148
175
  const complete = (): void => {
149
176
  if (completed) return
150
177
  completed = true
151
- onComplete(run)
178
+ // A run outlives the session that started it, and pi's loader wires assertActive()
179
+ // into every runtime call, so notifying a disposed session throws. This fires from
180
+ // the child's 'close'/'error' listener, where nothing upstream catches: an escaping
181
+ // error reaches Node as an uncaughtException and takes pi down with it. The run
182
+ // state is already recorded by this point, so there is nothing to do but drop the
183
+ // notification for a session that is no longer there to receive it.
184
+ try {
185
+ onComplete(run)
186
+ } catch {
187
+ // the session that asked for this run is gone
188
+ }
152
189
  }
153
190
  proc.stdout.on('data', (data) => {
154
191
  stdout += data.toString()
155
192
  })
193
+ // An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
194
+ // belongs to a detached child, so a pipe read failure would exit pi the same way an
195
+ // unguarded completion would. The foreground runner guards its streams the same way.
196
+ proc.stdout.on('error', () => {})
156
197
  proc.on('close', (code) => {
157
198
  const { text, turns } = parseFinalOutputFromJsonl(stdout)
158
199
  run.kill = undefined
@@ -626,8 +626,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
626
626
  }
627
627
  args.push(`Task: ${task}`)
628
628
  const invocation = getPiInvocation(args)
629
- const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
629
+ const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd, promptBody: tmpPrompt ? promptWithSkills : undefined }, (run) => {
630
630
  removeTmpPrompt(tmpPrompt)
631
+ // Both calls throw once the session that started the run is disposed; driveRun
632
+ // catches for the whole callback, so neither can escape into the child's close
633
+ // listener and become an uncaughtException.
631
634
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
632
635
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
633
636
  })
@@ -749,6 +752,10 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
749
752
  cwd: t.cwd,
750
753
  signal,
751
754
  onPhase: mode.onPhase,
755
+ // Same context single and chain mode pass: without these, an agent's skills
756
+ // preload and its model tier alias silently do nothing in parallel mode only.
757
+ skillRoots: mode.skillRoots,
758
+ availableModels: mode.availableModels,
752
759
  // Per-task update callback
753
760
  onUpdate: (partial) => {
754
761
  const live = partial.details?.results[0]
@@ -1102,6 +1109,7 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1102
1109
 
1103
1110
  export default function subagentExtension(pi: ExtensionAPI) {
1104
1111
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
1112
+ // Runs through driveRun's guard, same as the background-mode callback above.
1105
1113
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1106
1114
  }
1107
1115
 
@@ -335,8 +335,11 @@ export default function todoExtension(pi: ExtensionAPI) {
335
335
  const msg = entry.message
336
336
  if (msg.role !== 'toolResult' || msg.toolName !== TOOL_NAME) continue
337
337
 
338
- const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos: LegacyTodo[] }) | undefined
339
- if (details) {
338
+ const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos?: LegacyTodo[] }) | undefined
339
+ // pi persists a failed tool call as `details: {}`, which is truthy: a rejected
340
+ // or blocked call would otherwise throw here and break replay for the rest of
341
+ // the session, losing the list on every resume, fork and compaction.
342
+ if (Array.isArray(details?.todos)) {
340
343
  replayTodos = details.todos.map(normalizeTodo)
341
344
  replayNextId = details.nextId
342
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",